From 3b3b9a62ca5b7493c6f492a53a60891b48abed07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 24 Apr 2026 23:03:10 -0400 Subject: [PATCH 01/28] test(smartplaylists): add smart playlist e2e test suite (#5409) * test: add smart playlist e2e suite infrastructure * test: add string field smart playlist e2e tests * test: add numeric, boolean, tag, participant, annotation, logic, sorting smart playlist e2e tests * test: add playlist operator smart playlist e2e tests * test: add isNot and endsWith string field e2e tests * test: add date/time field smart playlist e2e tests * fix: add gosec nolint directives for safe SQL concatenation in e2e restore * refactor: address code review feedback for smart playlist e2e tests - Deduplicate evaluateRule by delegating to evaluateRuleOrdered - Cache table list in BeforeSuite instead of querying sqlite_master per test - Wrap restoreDB in a transaction with defer cleanup for DETACH/foreign_keys - Use JSON numbers for numeric criteria values to match canonical JSON shape * refactor: simplify e2e test infrastructure - Remove unused return value from buildTestFS - Add deferred ROLLBACK as safety net in restoreDB transaction - Cache Come Together ID to avoid repeated lookups in BeforeSuite - Use range-over-int for play count loop * test: add missing operator coverage to smart playlist e2e tests Add 4 tests for operators/paths that had no e2e coverage: - notContains on string fields (LIKE negation path) - before on date fields (Lt for dates, only after was tested) - startsWith on tag fields (json_tree + LIKE subquery) - endsWith on participant/role fields (json_tree + LIKE subquery) --- core/playlists/e2e/e2e_suite_test.go | 326 +++++++++++++++++++++++ core/playlists/e2e/smartplaylist_test.go | 290 ++++++++++++++++++++ 2 files changed, 616 insertions(+) create mode 100644 core/playlists/e2e/e2e_suite_test.go create mode 100644 core/playlists/e2e/smartplaylist_test.go diff --git a/core/playlists/e2e/e2e_suite_test.go b/core/playlists/e2e/e2e_suite_test.go new file mode 100644 index 000000000..e9a717d0a --- /dev/null +++ b/core/playlists/e2e/e2e_suite_test.go @@ -0,0 +1,326 @@ +package e2e + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "testing/fstest" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSmartPlaylistE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Smart Playlist E2E Suite") +} + +type _t = map[string]any + +var template = storagetest.Template +var track = storagetest.Track + +var ( + ctx context.Context + ds *tests.MockDataStore + lib model.Library + + dbFilePath string + snapshotPath string + snapshotTables []string + + testUser = model.User{ + ID: "sp-test-user-1", + UserName: "sptestuser", + Name: "SP Test User", + IsAdmin: true, + } + + otherUser = model.User{ + ID: "sp-test-user-2", + UserName: "spotheruser", + Name: "SP Other User", + IsAdmin: false, + } +) + +func buildTestFS() { + abbeyRoad := template(_t{ + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + }) + ledZepIV := template(_t{ + "albumartist": "Led Zeppelin", + "artist": "Led Zeppelin", + "album": "IV", + "year": 1971, + }) + kindOfBlue := template(_t{ + "albumartist": "Miles Davis", + "artist": "Miles Davis", + "album": "Kind of Blue", + "year": 1959, + "genre": "Jazz", + "composer": "Miles Davis", + }) + nightAtOpera := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "A Night at the Opera", + "year": 1975, + "genre": "Rock", + }) + electricLadyland := template(_t{ + "albumartist": "Jimi Hendrix", + "artist": "Jimi Hendrix", + "album": "Electric Ladyland", + "year": 1968, + "genre": "Rock;Blues", + }) + newsOfWorld := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "News of the World", + "year": 1977, + "genre": "Rock;Pop", + "compilation": "1", + }) + + fs := storagetest.FakeFS{} + fs.SetFiles(fstest.MapFS{ + "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", + _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", + _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What", + _t{"bpm": 136})), + "Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody", + _t{"composer": "Freddie Mercury", "bpm": 72})), + "Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower", + _t{"composer": "Bob Dylan", "bpm": 112})), + "Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions", + _t{"composer": "Freddie Mercury", "bpm": 64})), + }) + storagetest.Register("fake", &fs) +} + +func findMediaFileByTitle(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title) + return mfs[0].ID +} + +func evaluateRule(jsonRule string) []string { + titles := evaluateRuleOrdered(jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrdered(jsonRule string) []string { + var rules criteria.Criteria + err := json.Unmarshal([]byte(jsonRule), &rules) + Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) + + pls := &model.Playlist{ + Name: "test-smart-playlist", + OwnerID: testUser.ID, + Rules: &rules, + } + err = ds.Playlist(ctx).Put(pls) + Expect(err).ToNot(HaveOccurred()) + + loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + titles := make([]string, len(loaded.Tracks)) + for i, t := range loaded.Tracks { + titles[i] = t.Title + } + return titles +} + +func createPlaylist(owner model.User, public bool, titles ...string) string { + pls := &model.Playlist{ + Name: "ref-playlist", + OwnerID: owner.ID, + Public: public, + } + for _, title := range titles { + mfID := findMediaFileByTitle(title) + pls.AddMediaFilesByID([]string{mfID}) + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +func createPublicPlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, true, titles...) +} + +func createPrivatePlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, false, titles...) +} + +func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + var rules criteria.Criteria + Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) + pls := &model.Playlist{ + Name: "ref-smart-playlist", + OwnerID: owner.ID, + Public: true, + Rules: &rules, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), testUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") + snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + userWithPass := testUser + userWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) + + otherUserWithPass := otherUser + otherUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed()) + + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName) + Expect(err).ToNot(HaveOccurred()) + testUser.Libraries = loadedUser.Libraries + + loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName) + Expect(err).ToNot(HaveOccurred()) + otherUser.Libraries = loadedOther.Libraries + + ctx = request.WithUser(GinkgoT().Context(), testUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + comeTogetherID := findMediaFileByTitle("Come Together") + Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed()) + Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) + for range 10 { + Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed()) + } + Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) + + rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + snapshotTables = append(snapshotTables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }() + + _, err = sqlDB.Exec("BEGIN TRANSACTION") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("ROLLBACK") }() + + for _, table := range snapshotTables { + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("COMMIT") + Expect(err).ToNot(HaveOccurred()) +} + +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), testUser) + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + restoreDB() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} +} diff --git a/core/playlists/e2e/smartplaylist_test.go b/core/playlists/e2e/smartplaylist_test.go new file mode 100644 index 000000000..6e31c6787 --- /dev/null +++ b/core/playlists/e2e/smartplaylist_test.go @@ -0,0 +1,290 @@ +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart Playlists", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("String fields", func() { + It("matches by exact title", func() { + results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by title contains", func() { + results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by artist startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by title isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by artist endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + }) + + Describe("Numeric fields", func() { + It("matches by year greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions")) + }) + + It("matches by year less than", func() { + results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`) + Expect(results).To(ConsistOf("So What", "All Along the Watchtower")) + }) + + It("matches by BPM in range", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower")) + }) + }) + + Describe("Boolean fields", func() { + It("matches compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches non-compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower")) + }) + }) + + Describe("File type fields", func() { + It("matches by filetype", func() { + results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + }) + + Describe("Multi-valued tags", func() { + It("matches tracks with Blues genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + + It("excludes tracks with Rock genre", func() { + results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("matches genre contains", func() { + results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven")) + }) + + It("matches tracks with Pop genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches genre startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Participants", func() { + It("matches by exact composer", func() { + results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by composer contains", func() { + results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by composer isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower")) + }) + + It("matches by composer endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions")) + }) + }) + + Describe("Annotations", func() { + It("matches starred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches unstarred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by rating greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("matches by rating greater than or equal via inTheRange", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches by play count greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by play count greater than zero", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + }) + + Describe("Negated string operators", func() { + It("matches by title notContains", func() { + results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody")) + }) + }) + + Describe("Date/time fields", func() { + It("matches dateAdded before a far-future date", func() { + results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches lastPlayed inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + + It("matches lastPlayed notInTheLast (far future)", func() { + results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches dateLoved after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches dateRated after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches dateAdded inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Logic operators", func() { + It("matches with ALL (AND)", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) + Expect(results).To(ConsistOf("Black Dog")) + }) + + It("matches with ANY (OR)", func() { + results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("So What", "We Are the Champions")) + }) + + It("matches nested all/any", func() { + results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + }) + + Describe("Sorting and limits", func() { + It("returns tracks sorted by year descending with limit", func() { + results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`) + Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"})) + }) + + It("returns tracks sorted by title ascending", func() { + results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`) + Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"})) + }) + }) + + Describe("Combined real-world patterns", func() { + It("matches genre filter with exclusion and year range", func() { + results := evaluateRuleOrdered(`{ + "all":[ + {"any":[ + {"is":{"genre":"Blues"}}, + {"is":{"genre":"Folk"}} + ]}, + {"isNot":{"genre":"Jazz"}}, + {"gt":{"year":1965}} + ], + "sort":"-year,title" + }`) + Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"})) + }) + }) + + Describe("Playlist operators", func() { + It("matches tracks in a public regular playlist", func() { + refID := createPublicPlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches tracks not in a public regular playlist", func() { + refID := createPublicPlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("recursively refreshes a referenced smart playlist owned by the same user", func() { + smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("does not refresh a referenced smart playlist owned by another user", func() { + smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("does not match tracks from a private playlist", func() { + refID := createPrivatePlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("does not match tracks from a private playlist owned by another user", func() { + refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + }) +}) From 251cc71e2dabf56cd8ef59cf107f6e732ea7262c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 24 Apr 2026 23:18:20 -0400 Subject: [PATCH 02/28] refactor: move smart playlist criteria SQL to persistence (#5408) * refactor: move criteria SQL generation to persistence Keep model/criteria as a domain DSL with JSON parsing, field metadata, expression traversal, and child playlist extraction only. Move smart playlist SQL translation, sort SQL, and join planning into persistence behind smartPlaylistCriteria so repository code uses a small query-building API. * refactor: simplify criteria translator metadata Use generic helper functions for criteria operator maps so the SQL translator can pass named criteria map types directly. Remove unused pseudo-field metadata from the criteria field API while preserving special field name lookup. * test: add coverage check for criteria-to-SQL field mappings Add a test that iterates all fields registered in the criteria package and verifies that every non-tag/non-role field has a corresponding entry in the persistence layer's smartPlaylistFields map. This prevents silent drift between the domain field registry and the SQL translation layer. Also adds an AllFieldNames() function to the criteria package to support field enumeration from outside the package. --- model/criteria/criteria.go | 114 +------ model/criteria/criteria_test.go | 209 +------------ model/criteria/fields.go | 368 +++++++--------------- model/criteria/fields_test.go | 57 +++- model/criteria/operators.go | 219 ++----------- model/criteria/operators_test.go | 177 ----------- model/criteria/walk.go | 72 +++++ model/criteria/walk_test.go | 64 ++++ persistence/criteria_sql.go | 487 +++++++++++++++++++++++++++++ persistence/criteria_sql_test.go | 170 ++++++++++ persistence/playlist_repository.go | 40 ++- 11 files changed, 1017 insertions(+), 960 deletions(-) create mode 100644 model/criteria/walk.go create mode 100644 model/criteria/walk_test.go create mode 100644 persistence/criteria_sql.go create mode 100644 persistence/criteria_sql_test.go diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 278acf34c..1e161c9f2 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -1,17 +1,16 @@ -// Package criteria implements a Criteria API based on Masterminds/squirrel +// Package criteria implements the smart playlist criteria DSL. package criteria import ( "encoding/json" "errors" - "fmt" - "strings" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" ) -type Expression = squirrel.Sqlizer +type Expression interface { + criteriaExpression() +} type Criteria struct { Expression @@ -49,115 +48,12 @@ func (c Criteria) IsPercentageLimit() bool { return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100 } -func (c Criteria) OrderBy() string { - if c.Sort == "" { - c.Sort = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) - order = "" - } - - parts := strings.Split(c.Sort, ",") - fields := make([]string, 0, len(parts)) - - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - - dir := "asc" - if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") { - if strings.HasPrefix(p, "-") { - dir = "desc" - } - p = strings.TrimSpace(p[1:]) - } - - sortField := strings.ToLower(p) - f := fieldMap[sortField] - if f == nil { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - - var mapped string - - if f.order != "" { - mapped = f.order - } else if f.isTag { - // Use the actual field name (handles aliases like albumtype -> releasetype) - tagName := sortField - if f.field != "" { - tagName = f.field - } - mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" - } else if f.isRole { - mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" - } else { - mapped = f.field - } - if f.numeric { - mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) - } - // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction. - // This ensures that the global order applies consistently across all fields. - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - - fields = append(fields, mapped+" "+dir) - } - - return strings.Join(fields, ", ") -} - -func (c Criteria) ToSql() (sql string, args []any, err error) { - return c.Expression.ToSql() -} - -// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression, -// excluding any JOINs required solely for sorting. This is useful for COUNT -// queries where sort order is irrelevant. -func (c Criteria) ExpressionJoins() JoinType { - if c.Expression == nil { - return JoinNone - } - return extractJoinTypes(c.Expression) -} - -// RequiredJoins inspects the expression tree and Sort field to determine which -// additional JOINs are needed when evaluating this criteria. -func (c Criteria) RequiredJoins() JoinType { - result := JoinNone - if c.Expression != nil { - result |= extractJoinTypes(c.Expression) - } - // Also check Sort fields - if c.Sort != "" { - for _, p := range strings.Split(c.Sort, ",") { - p = strings.TrimSpace(p) - p = strings.TrimLeft(p, "+-") - p = strings.TrimSpace(p) - result |= fieldJoinType(p) - } - } - return result -} - func (c Criteria) ChildPlaylistIds() []string { if c.Expression == nil { return nil } - if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil { + if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok { return parent.ChildPlaylistIds() } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index a76b3fc1f..e0940a509 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() { } jsonObj = b.String() }) - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` + - `AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` + - `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` + - `AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`)) - gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3)) - }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) @@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() { gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) }) - Describe("OrderBy", func() { - It("sorts by regular fields", func() { - gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc")) - }) - - It("sorts by tag fields", func() { - goObj.Sort = "genre" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc", - ), - ) - }) - - It("sorts by role fields", func() { - goObj.Sort = "artist" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc", - ), - ) - }) - - It("casts numeric tags when sorting", func() { - AddTagNames([]string{"rate"}) - AddNumericTags([]string{"rate"}) - goObj.Sort = "rate" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"), - ) - }) - - It("sorts by albumtype alias (resolves to releasetype)", func() { - AddTagNames([]string{"releasetype"}) - goObj.Sort = "albumtype" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", - ), - ) - }) - - It("sorts by random", func() { - newObj := goObj - newObj.Sort = "random" - gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) - }) - - It("sorts by multiple fields", func() { - goObj.Sort = "title,-rating" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc, COALESCE(annotation.rating, 0) desc", - )) - }) - - It("reverts order when order is desc", func() { - goObj.Sort = "-date,artist" - goObj.Order = "desc" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc", - )) - }) - - It("ignores invalid sort fields", func() { - goObj.Sort = "bogus,title" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc", - )) - }) - }) - }) - - Context("with artist roles", func() { - BeforeEach(func() { - goObj = Criteria{ - Expression: All{ - Is{"artist": "The Beatles"}, - Contains{"composer": "Lennon"}, - }, - } - }) - - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` + - `exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`, - )) - gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%")) - }) - }) - - Describe("ExpressionJoins", func() { - It("excludes sort-only joins", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone)) - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - - It("includes expression-based joins", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - }) - - Describe("RequiredJoins", func() { - It("returns JoinNone when no annotation fields are used", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinNone for media_file annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"loved": true}, - Gt{"playCount": 5}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinAlbumAnnotation for album annotation fields", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation)) - }) - It("returns JoinArtistAnnotation for artist annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"artistLoved": true}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation)) - }) - It("returns both join types when both are used", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - Is{"artistLoved": true}, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types in nested expressions", func() { - c := Criteria{ - Expression: All{ - Any{ - All{ - Is{"albumLoved": true}, - }, - }, - Any{ - Gt{"artistPlayCount": 10}, - }, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field with direction prefix", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "-artistRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) }) Describe("LimitPercent", func() { @@ -470,5 +265,9 @@ var _ = Describe("Criteria", func() { ids := Criteria{}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("returns empty list for leaf expressions", func() { + ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.BeEmpty()) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index bc3c7a3d3..30541a945 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -1,280 +1,133 @@ package criteria -import ( - "fmt" - "reflect" - "strings" +import "strings" - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" -) - -// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression. -type JoinType int - -const ( - JoinNone JoinType = 0 - JoinAlbumAnnotation JoinType = 1 << iota - JoinArtistAnnotation -) - -// Has returns true if j contains all bits in other. -func (j JoinType) Has(other JoinType) bool { return j&other != 0 } - -var fieldMap = map[string]*mappedField{ - "title": {field: "media_file.title"}, - "album": {field: "media_file.album"}, - "hascoverart": {field: "media_file.has_cover_art"}, - "tracknumber": {field: "media_file.track_number"}, - "discnumber": {field: "media_file.disc_number"}, - "year": {field: "media_file.year"}, - "date": {field: "media_file.date", alias: "recordingdate"}, - "originalyear": {field: "media_file.original_year"}, - "originaldate": {field: "media_file.original_date"}, - "releaseyear": {field: "media_file.release_year"}, - "releasedate": {field: "media_file.release_date"}, - "size": {field: "media_file.size"}, - "compilation": {field: "media_file.compilation"}, - "missing": {field: "media_file.missing"}, - "explicitstatus": {field: "media_file.explicit_status"}, - "dateadded": {field: "media_file.created_at"}, - "datemodified": {field: "media_file.updated_at"}, - "discsubtitle": {field: "media_file.disc_subtitle"}, - "comment": {field: "media_file.comment"}, - "lyrics": {field: "media_file.lyrics"}, - "sorttitle": {field: "media_file.sort_title"}, - "sortalbum": {field: "media_file.sort_album_name"}, - "sortartist": {field: "media_file.sort_artist_name"}, - "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumcomment": {field: "media_file.mbz_album_comment"}, - "catalognumber": {field: "media_file.catalog_num"}, - "filepath": {field: "media_file.path"}, - "filetype": {field: "media_file.suffix"}, - "codec": {field: "media_file.codec"}, - "duration": {field: "media_file.duration"}, - "bitrate": {field: "media_file.bit_rate"}, - "bitdepth": {field: "media_file.bit_depth"}, - "samplerate": {field: "media_file.sample_rate"}, - "bpm": {field: "media_file.bpm"}, - "channels": {field: "media_file.channels"}, - "loved": {field: "COALESCE(annotation.starred, false)"}, - "dateloved": {field: "annotation.starred_at"}, - "lastplayed": {field: "annotation.play_date"}, - "daterated": {field: "annotation.rated_at"}, - "playcount": {field: "COALESCE(annotation.play_count, 0)"}, - "rating": {field: "COALESCE(annotation.rating, 0)"}, - "averagerating": {field: "media_file.average_rating", numeric: true}, - "albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation}, - "albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation}, - "albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation}, - "albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation}, - "albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation}, - "albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation}, - - "artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation}, - "artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation}, - "artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation}, - "artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation}, - "artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation}, - "artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation}, - - "mbz_album_id": {field: "media_file.mbz_album_id"}, - "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, - "mbz_artist_id": {field: "media_file.mbz_artist_id"}, - "mbz_recording_id": {field: "media_file.mbz_recording_id"}, - "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, - "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, - "library_id": {field: "media_file.library_id", numeric: true}, - - // Backward compatibility: albumtype is an alias for releasetype tag - "albumtype": {field: "releasetype", isTag: true}, - - // special fields - "random": {field: "", order: "random()"}, // pseudo-field for random sorting - "value": {field: "value"}, // pseudo-field for tag and roles values +// FieldInfo describes a criteria field without tying it to persistence details. +type FieldInfo struct { + Name string + IsTag bool + IsRole bool + Numeric bool } -type mappedField struct { - field string - order string - isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) - isTag bool // true if the field is a tag imported from the file metadata - alias string // name from `mappings.yml` that may differ from the name used in the smart playlist - numeric bool // true if the field/tag should be treated as numeric - joinType JoinType // which additional JOINs this field requires +var fieldMap = map[string]*fieldMetadata{ + "title": {name: "title"}, + "album": {name: "album"}, + "hascoverart": {name: "hascoverart"}, + "tracknumber": {name: "tracknumber"}, + "discnumber": {name: "discnumber"}, + "year": {name: "year"}, + "date": {name: "date", alias: "recordingdate"}, + "originalyear": {name: "originalyear"}, + "originaldate": {name: "originaldate"}, + "releaseyear": {name: "releaseyear"}, + "releasedate": {name: "releasedate"}, + "size": {name: "size"}, + "compilation": {name: "compilation"}, + "missing": {name: "missing"}, + "explicitstatus": {name: "explicitstatus"}, + "dateadded": {name: "dateadded"}, + "datemodified": {name: "datemodified"}, + "discsubtitle": {name: "discsubtitle"}, + "comment": {name: "comment"}, + "lyrics": {name: "lyrics"}, + "sorttitle": {name: "sorttitle"}, + "sortalbum": {name: "sortalbum"}, + "sortartist": {name: "sortartist"}, + "sortalbumartist": {name: "sortalbumartist"}, + "albumcomment": {name: "albumcomment"}, + "catalognumber": {name: "catalognumber"}, + "filepath": {name: "filepath"}, + "filetype": {name: "filetype"}, + "codec": {name: "codec"}, + "duration": {name: "duration"}, + "bitrate": {name: "bitrate"}, + "bitdepth": {name: "bitdepth"}, + "samplerate": {name: "samplerate"}, + "bpm": {name: "bpm"}, + "channels": {name: "channels"}, + "loved": {name: "loved"}, + "dateloved": {name: "dateloved"}, + "lastplayed": {name: "lastplayed"}, + "daterated": {name: "daterated"}, + "playcount": {name: "playcount"}, + "rating": {name: "rating"}, + "averagerating": {name: "averagerating", numeric: true}, + "albumrating": {name: "albumrating"}, + "albumloved": {name: "albumloved"}, + "albumplaycount": {name: "albumplaycount"}, + "albumlastplayed": {name: "albumlastplayed"}, + "albumdateloved": {name: "albumdateloved"}, + "albumdaterated": {name: "albumdaterated"}, + "artistrating": {name: "artistrating"}, + "artistloved": {name: "artistloved"}, + "artistplaycount": {name: "artistplaycount"}, + "artistlastplayed": {name: "artistlastplayed"}, + "artistdateloved": {name: "artistdateloved"}, + "artistdaterated": {name: "artistdaterated"}, + "mbz_album_id": {name: "mbz_album_id"}, + "mbz_album_artist_id": {name: "mbz_album_artist_id"}, + "mbz_artist_id": {name: "mbz_artist_id"}, + "mbz_recording_id": {name: "mbz_recording_id"}, + "mbz_release_track_id": {name: "mbz_release_track_id"}, + "mbz_release_group_id": {name: "mbz_release_group_id"}, + "library_id": {name: "library_id", numeric: true}, + + // Backward compatibility: albumtype is an alias for the releasetype tag. + "albumtype": {name: "releasetype", isTag: true}, + + "random": {name: "random"}, + "value": {name: "value"}, } -func mapFields(expr map[string]any) map[string]any { - m := make(map[string]any) - for f, v := range expr { - if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" { - m[dbf.field] = v - } else { - log.Error("Invalid field in criteria", "field", f) - } +type fieldMetadata struct { + name string + isRole bool + isTag bool + alias string + numeric bool +} + +// AllFieldNames returns the names of all registered criteria fields. +func AllFieldNames() []string { + names := make([]string, 0, len(fieldMap)) + for name := range fieldMap { + names = append(names, name) } - return m + return names } -// mapExpr maps a normal field expression to a specific type of expression (tag or role). -// This is required because tags are handled differently than other fields, -// as they are stored as a JSON column in the database. -func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer { - rv := reflect.ValueOf(expr) - if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String { - log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr)) +// LookupField returns semantic metadata for a criteria field name. +func LookupField(name string) (FieldInfo, bool) { + f, ok := fieldMap[strings.ToLower(name)] + if !ok { + return FieldInfo{}, false } - - // Extract the field name and value, then build a new map keyed by "value" - // for the inner condition. The original map is left untouched so that - // ToSql can be called multiple times without corruption. - var k string - var v any - for _, key := range rv.MapKeys() { - k = key.String() - v = rv.MapIndex(key).Interface() - break // only one key is expected (and supported) - } - - // Create a new map-based expression with "value" as the key, matching the - // column name inside json_tree subqueries. - newMap := reflect.MakeMap(rv.Type()) - newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v)) - newExpr := newMap.Interface().(squirrel.Sqlizer) - - return exprFunc(k, newExpr, negate) -} - -// mapTagExpr maps a normal field expression to a tag expression. -func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, tagExpr) -} - -// mapRoleExpr maps a normal field expression to an artist role expression. -func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, roleExpr) -} - -func isTagExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag { - return true - } - } - return false -} - -func isRoleExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole { - return true - } - } - return false -} - -func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return tagCond{tag: tag, cond: cond, not: negate} -} - -type tagCond struct { - tag string - cond squirrel.Sqlizer - not bool -} - -func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - - // Resolve the actual tag name (handles aliases like albumtype -> releasetype) - tagName := e.tag - if fm, ok := fieldMap[e.tag]; ok { - if fm.field != "" { - tagName = fm.field - } - if fm.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") - } - } - - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", - tagName, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return roleCond{role: role, cond: cond, not: negate} -} - -type roleCond struct { - role string - cond squirrel.Sqlizer - not bool -} - -func (e roleCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf(`exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)`, - e.role, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -// fieldJoinType returns the JoinType for a given field name (case-insensitive). -func fieldJoinType(name string) JoinType { - if f, ok := fieldMap[strings.ToLower(name)]; ok { - return f.joinType - } - return JoinNone -} - -// extractJoinTypes walks an expression tree and collects all required JoinType flags. -func extractJoinTypes(expr any) JoinType { - result := JoinNone - switch e := expr.(type) { - case All: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - case Any: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - default: - // Leaf expression: use reflection to check if it's a map with field names - rv := reflect.ValueOf(expr) - if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { - for _, key := range rv.MapKeys() { - result |= fieldJoinType(key.String()) - } - } - } - return result + return FieldInfo{ + Name: f.name, + IsTag: f.isTag, + IsRole: f.isRole, + Numeric: f.numeric, + }, true } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in -// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent. +// smart playlists. func AddRoles(roles []string) { for _, role := range roles { name := strings.ToLower(role) if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &mappedField{field: name, isRole: true} + fieldMap[name] = &fieldMetadata{name: name, isRole: true} } } // AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` -// file to the field map, so they can be used in smart playlists. -// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent. +// configuration file. func AddTagNames(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if _, ok := fieldMap[name]; ok { continue } @@ -285,20 +138,19 @@ func AddTagNames(tagNames []string) { } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &mappedField{field: name, isTag: true} + fieldMap[name] = &fieldMetadata{name: name, isTag: true} } } } -// AddNumericTags marks the given tag names as numeric so they can be cast -// when used in comparisons or sorting. +// AddNumericTags adds tags that should be treated as numbers. func AddNumericTags(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { fm.numeric = true } else { - fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true} + fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index accdebd3d..ecbeb5857 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -6,11 +6,58 @@ import ( ) var _ = Describe("fields", func() { - Describe("mapFields", func() { - It("ignores random fields", func() { - m := map[string]any{"random": "123"} - m = mapFields(m) - gomega.Expect(m).To(gomega.BeEmpty()) + Describe("LookupField", func() { + It("finds built-in fields case-insensitively", func() { + field, ok := LookupField("Title") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + }) + + It("resolves aliases to their semantic field name", func() { + field, ok := LookupField("albumtype") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("finds special fields", func() { + field, ok := LookupField("value") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("value")) + }) + + It("finds registered tag names", func() { + AddTagNames([]string{"task3_mood"}) + + field, ok := LookupField("task3_mood") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("marks registered numeric tags", func() { + AddTagNames([]string{"task3_score"}) + AddNumericTags([]string{"task3_score"}) + + field, ok := LookupField("task3_score") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + gomega.Expect(field.Numeric).To(gomega.BeTrue()) + }) + + It("finds registered roles", func() { + AddRoles([]string{"task3_producer"}) + + field, ok := LookupField("task3_producer") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) }) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 336f914de..6f911b12f 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,23 +1,13 @@ package criteria -import ( - "errors" - "fmt" - "reflect" - "strconv" - "time" - - "github.com/Masterminds/squirrel" -) +import "time" type ( - All squirrel.And + All []Expression And = All ) -func (all All) ToSql() (sql string, args []any, err error) { - return squirrel.And(all).ToSql() -} +func (All) criteriaExpression() {} func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -28,13 +18,11 @@ func (all All) ChildPlaylistIds() (ids []string) { } type ( - Any squirrel.Or + Any []Expression Or = Any ) -func (any Any) ToSql() (sql string, args []any, err error) { - return squirrel.Or(any).ToSql() -} +func (Any) criteriaExpression() {} func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -44,70 +32,42 @@ func (any Any) ChildPlaylistIds() (ids []string) { return extractPlaylistIds(any) } -type Is squirrel.Eq +type Is map[string]any type Eq = Is -func (is Is) ToSql() (sql string, args []any, err error) { - if isRoleExpr(is) { - return mapRoleExpr(is, false).ToSql() - } - if isTagExpr(is) { - return mapTagExpr(is, false).ToSql() - } - return squirrel.Eq(mapFields(is)).ToSql() -} +func (Is) criteriaExpression() {} func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } -type IsNot squirrel.NotEq +type IsNot map[string]any -func (in IsNot) ToSql() (sql string, args []any, err error) { - if isRoleExpr(in) { - return mapRoleExpr(squirrel.Eq(in), true).ToSql() - } - if isTagExpr(in) { - return mapTagExpr(squirrel.Eq(in), true).ToSql() - } - return squirrel.NotEq(mapFields(in)).ToSql() -} +func (IsNot) criteriaExpression() {} func (in IsNot) MarshalJSON() ([]byte, error) { return marshalExpression("isNot", in) } -type Gt squirrel.Gt +type Gt map[string]any -func (gt Gt) ToSql() (sql string, args []any, err error) { - if isTagExpr(gt) { - return mapTagExpr(gt, false).ToSql() - } - return squirrel.Gt(mapFields(gt)).ToSql() -} +func (Gt) criteriaExpression() {} func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt squirrel.Lt +type Lt map[string]any -func (lt Lt) ToSql() (sql string, args []any, err error) { - if isTagExpr(lt) { - return mapTagExpr(squirrel.Lt(lt), false).ToSql() - } - return squirrel.Lt(mapFields(lt)).ToSql() -} +func (Lt) criteriaExpression() {} func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before squirrel.Lt +type Before map[string]any -func (bf Before) ToSql() (sql string, args []any, err error) { - return Lt(bf).ToSql() -} +func (Before) criteriaExpression() {} func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) @@ -115,9 +75,7 @@ func (bf Before) MarshalJSON() ([]byte, error) { type After Gt -func (af After) ToSql() (sql string, args []any, err error) { - return Gt(af).ToSql() -} +func (After) criteriaExpression() {} func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) @@ -125,19 +83,7 @@ func (af After) MarshalJSON() ([]byte, error) { type Contains map[string]any -func (ct Contains) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(ct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(ct) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(ct) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (Contains) criteriaExpression() {} func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) @@ -145,19 +91,7 @@ func (ct Contains) MarshalJSON() ([]byte, error) { type NotContains map[string]any -func (nct NotContains) ToSql() (sql string, args []any, err error) { - lk := squirrel.NotLike{} - for f, v := range mapFields(nct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(nct) { - return mapRoleExpr(squirrel.Like(lk), true).ToSql() - } - if isTagExpr(nct) { - return mapTagExpr(squirrel.Like(lk), true).ToSql() - } - return lk.ToSql() -} +func (NotContains) criteriaExpression() {} func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) @@ -165,19 +99,7 @@ func (nct NotContains) MarshalJSON() ([]byte, error) { type StartsWith map[string]any -func (sw StartsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%s%%", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (StartsWith) criteriaExpression() {} func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) @@ -185,19 +107,7 @@ func (sw StartsWith) MarshalJSON() ([]byte, error) { type EndsWith map[string]any -func (sw EndsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%%%s", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (EndsWith) criteriaExpression() {} func (sw EndsWith) MarshalJSON() ([]byte, error) { return marshalExpression("endsWith", sw) @@ -205,20 +115,7 @@ func (sw EndsWith) MarshalJSON() ([]byte, error) { type InTheRange map[string]any -func (itr InTheRange) ToSql() (sql string, args []any, err error) { - and := squirrel.And{} - for f, v := range mapFields(itr) { - s := reflect.ValueOf(v) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v) - } - and = append(and, - squirrel.GtOrEq{f: s.Index(0).Interface()}, - squirrel.LtOrEq{f: s.Index(1).Interface()}, - ) - } - return and.ToSql() -} +func (InTheRange) criteriaExpression() {} func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) @@ -226,13 +123,7 @@ func (itr InTheRange) MarshalJSON() ([]byte, error) { type InTheLast map[string]any -func (itl InTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(itl, false) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +func (InTheLast) criteriaExpression() {} func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) @@ -240,50 +131,19 @@ func (itl InTheLast) MarshalJSON() ([]byte, error) { type NotInTheLast map[string]any -func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(nitl, true) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +func (NotInTheLast) criteriaExpression() {} func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } -func inPeriod(m map[string]any, negate bool) (Expression, error) { - var field string - var value any - for f, v := range mapFields(m) { - field, value = f, v - break - } - str := fmt.Sprintf("%v", value) - v, err := strconv.ParseInt(str, 10, 64) - if err != nil { - return nil, err - } - firstDate := startOfPeriod(v, time.Now()) - - if negate { - return Or{ - squirrel.Lt{field: firstDate}, - squirrel.Eq{field: nil}, - }, nil - } - return squirrel.Gt{field: firstDate}, nil -} - func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } type InPlaylist map[string]any -func (ipl InPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, false) -} +func (InPlaylist) criteriaExpression() {} func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) @@ -291,41 +151,12 @@ func (ipl InPlaylist) MarshalJSON() ([]byte, error) { type NotInPlaylist map[string]any -func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, true) -} +func (NotInPlaylist) criteriaExpression() {} func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("notInPlaylist", ipl) } -func inList(m map[string]any, negate bool) (sql string, args []any, err error) { - var playlistid string - var ok bool - if playlistid, ok = m["id"].(string); !ok { - return "", nil, errors.New("playlist id not given") - } - - // Subquery to fetch all media files that are contained in given playlist - // Only evaluate playlist if it is public - subQuery := squirrel.Select("media_file_id"). - From("playlist_tracks pl"). - LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistid}, - squirrel.Eq{"playlist.public": 1}}) - subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() - - if err != nil { - return "", nil, err - } - if negate { - return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil - } else { - return "media_file.id IN (" + subQText + ")", subQArgs, nil - } -} - func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 5f756f97d..c93e8f2b2 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -3,7 +3,6 @@ package criteria_test import ( "encoding/json" "fmt" - "time" . "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" @@ -17,182 +16,6 @@ var _ = BeforeSuite(func() { }) var _ = Describe("Operators", func() { - rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local) - rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local) - - DescribeTable("ToSQL", - func(op Expression, expectedSql string, expectedArgs ...any) { - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal(expectedSql)) - gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...)) - }, - Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), - Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1), - Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), - Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1), - Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2), - Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), - Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), - Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), - Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), - Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), - Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), - Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd), - Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart), - Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), - - // InPlaylist and NotInPlaylist are special cases - Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+ - "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+ - "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - - Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Album annotation fields - Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true), - Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5), - Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart), - Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart), - Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart), - Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Artist annotation fields - Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3), - Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), - Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5), - Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart), - Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart), - Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart), - Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Tag tests - Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"), - Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"), - Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), - Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), - - // Artist roles tests - Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"), - Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), - ) - - // TODO Validate operators that are not valid for each field type. - XDescribeTable("ToSQL - Invalid Operators", - func(op Expression, expectedError string) { - _, _, err := op.ToSql() - gomega.Expect(err).To(gomega.MatchError(expectedError)) - }, - Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"), - ) - - Describe("Custom Tags", func() { - It("generates valid SQL", func() { - AddTagNames([]string{"mood"}) - op := EndsWith{"mood": "Soft"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) - }) - It("casts numeric comparisons", func() { - AddNumericTags([]string{"rate"}) - op := Lt{"rate": 6} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) - gomega.Expect(args).To(gomega.HaveExactElements(6)) - }) - It("skips unknown tag names", func() { - op := EndsWith{"unknown": "value"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - It("supports releasetype as multi-valued tag", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"releasetype": "soundtrack"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) - }) - It("supports albumtype as alias for releasetype", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"albumtype": "live"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%live%")) - }) - It("supports albumtype alias with Is operator", func() { - AddTagNames([]string{"releasetype"}) - op := Is{"albumtype": "album"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("album")) - }) - It("supports albumtype alias with IsNot operator", func() { - AddTagNames([]string{"releasetype"}) - op := IsNot{"albumtype": "compilation"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("compilation")) - }) - }) - - Describe("Custom Roles", func() { - It("generates valid SQL", func() { - AddRoles([]string{"producer"}) - op := EndsWith{"producer": "Eno"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) - }) - It("skips unknown roles", func() { - op := Contains{"groupie": "Penny Lane"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - }) - - DescribeTable("ToSql idempotency", - func(expr Expression) { - sql1, args1, err1 := expr.ToSql() - sql2, args2, err2 := expr.ToSql() - - gomega.Expect(err1).ToNot(gomega.HaveOccurred()) - gomega.Expect(err2).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql2).To(gomega.Equal(sql1)) - gomega.Expect(args2).To(gomega.Equal(args1)) - }, - Entry("tag expression", Is{"genre": "Rock"}), - Entry("role expression", Contains{"artist": "Beatles"}), - Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}), - ) - DescribeTable("JSON Marshaling", func(op Expression, jsonString string) { obj := And{op} diff --git a/model/criteria/walk.go b/model/criteria/walk.go new file mode 100644 index 000000000..62aaf97f8 --- /dev/null +++ b/model/criteria/walk.go @@ -0,0 +1,72 @@ +package criteria + +import "fmt" + +type Visitor func(Expression) error + +func Walk(expr Expression, visit Visitor) error { + if expr == nil { + return nil + } + if err := visit(expr); err != nil { + return err + } + switch e := expr.(type) { + case All: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Any: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + return nil + default: + return fmt.Errorf("unknown criteria expression type %T", expr) + } + return nil +} + +// Fields returns field values for leaf expressions only. +// Use Walk to traverse All and Any expressions before calling Fields. +func Fields(expr Expression) map[string]any { + switch e := expr.(type) { + case Is: + return map[string]any(e) + case IsNot: + return map[string]any(e) + case Gt: + return map[string]any(e) + case Lt: + return map[string]any(e) + case Before: + return map[string]any(e) + case After: + return map[string]any(Gt(e)) + case Contains: + return map[string]any(e) + case NotContains: + return map[string]any(e) + case StartsWith: + return map[string]any(e) + case EndsWith: + return map[string]any(e) + case InTheRange: + return map[string]any(e) + case InTheLast: + return map[string]any(e) + case NotInTheLast: + return map[string]any(e) + case InPlaylist: + return map[string]any(e) + case NotInPlaylist: + return map[string]any(e) + default: + return nil + } +} diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go new file mode 100644 index 000000000..91438f095 --- /dev/null +++ b/model/criteria/walk_test.go @@ -0,0 +1,64 @@ +package criteria + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +type unknownExpression struct{} + +func (unknownExpression) criteriaExpression() {} + +var _ = Describe("Walk", func() { + It("visits the expression tree depth-first", func() { + expr := All{ + Contains{"title": "love"}, + Any{ + Is{"album": "best of"}, + Gt{"rating": 3}, + }, + } + + var visited []string + err := Walk(expr, func(expr Expression) error { + visited = append(visited, fmt.Sprintf("%T", expr)) + return nil + }) + + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(visited).To(gomega.Equal([]string{ + "criteria.All", + "criteria.Contains", + "criteria.Any", + "criteria.Is", + "criteria.Gt", + })) + }) + + It("stops when the visitor returns an error", func() { + expectedErr := fmt.Errorf("stop") + + err := Walk(All{Contains{"title": "love"}}, func(Expression) error { + return expectedErr + }) + + gomega.Expect(err).To(gomega.MatchError(expectedErr)) + }) + + It("returns fields for leaf expressions", func() { + gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"})) + gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"})) + }) + + It("returns nil fields for group expressions", func() { + gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil()) + }) + + It("returns an error for unknown expression types", func() { + err := Walk(unknownExpression{}, func(Expression) error { return nil }) + + gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression")) + }) +}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go new file mode 100644 index 000000000..2f7b73885 --- /dev/null +++ b/persistence/criteria_sql.go @@ -0,0 +1,487 @@ +package persistence + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + squirrel "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/criteria" +) + +type smartPlaylistJoinType int + +const ( + smartPlaylistJoinNone smartPlaylistJoinType = 0 + smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota + smartPlaylistJoinArtistAnnotation +) + +func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { + return j&other != 0 +} + +type smartPlaylistField struct { + expr string + order string + joinType smartPlaylistJoinType +} + +type smartPlaylistCriteria struct { + criteria criteria.Criteria +} + +func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria { + return smartPlaylistCriteria{criteria: c} +} + +var smartPlaylistFields = map[string]smartPlaylistField{ + "title": {expr: "media_file.title"}, + "album": {expr: "media_file.album"}, + "hascoverart": {expr: "media_file.has_cover_art"}, + "tracknumber": {expr: "media_file.track_number"}, + "discnumber": {expr: "media_file.disc_number"}, + "year": {expr: "media_file.year"}, + "date": {expr: "media_file.date"}, + "originalyear": {expr: "media_file.original_year"}, + "originaldate": {expr: "media_file.original_date"}, + "releaseyear": {expr: "media_file.release_year"}, + "releasedate": {expr: "media_file.release_date"}, + "size": {expr: "media_file.size"}, + "compilation": {expr: "media_file.compilation"}, + "missing": {expr: "media_file.missing"}, + "explicitstatus": {expr: "media_file.explicit_status"}, + "dateadded": {expr: "media_file.created_at"}, + "datemodified": {expr: "media_file.updated_at"}, + "discsubtitle": {expr: "media_file.disc_subtitle"}, + "comment": {expr: "media_file.comment"}, + "lyrics": {expr: "media_file.lyrics"}, + "sorttitle": {expr: "media_file.sort_title"}, + "sortalbum": {expr: "media_file.sort_album_name"}, + "sortartist": {expr: "media_file.sort_artist_name"}, + "sortalbumartist": {expr: "media_file.sort_album_artist_name"}, + "albumcomment": {expr: "media_file.mbz_album_comment"}, + "catalognumber": {expr: "media_file.catalog_num"}, + "filepath": {expr: "media_file.path"}, + "filetype": {expr: "media_file.suffix"}, + "codec": {expr: "media_file.codec"}, + "duration": {expr: "media_file.duration"}, + "bitrate": {expr: "media_file.bit_rate"}, + "bitdepth": {expr: "media_file.bit_depth"}, + "samplerate": {expr: "media_file.sample_rate"}, + "bpm": {expr: "media_file.bpm"}, + "channels": {expr: "media_file.channels"}, + "loved": {expr: "COALESCE(annotation.starred, false)"}, + "dateloved": {expr: "annotation.starred_at"}, + "lastplayed": {expr: "annotation.play_date"}, + "daterated": {expr: "annotation.rated_at"}, + "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, + "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "averagerating": {expr: "media_file.average_rating"}, + "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, + "mbz_album_id": {expr: "media_file.mbz_album_id"}, + "mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"}, + "mbz_artist_id": {expr: "media_file.mbz_artist_id"}, + "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, + "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, + "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "library_id": {expr: "media_file.library_id"}, + "random": {order: "random()"}, + "value": {expr: "value"}, +} + +func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { + if c.criteria.Expression == nil { + return squirrel.Expr("1 = 1"), nil + } + return exprSQL(c.criteria.Expression) +} + +func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { + switch e := expr.(type) { + case criteria.All: + and := squirrel.And{} + for _, child := range e { + cond, err := exprSQL(child) + if err != nil { + return nil, err + } + and = append(and, cond) + } + return and, nil + case criteria.Any: + or := squirrel.Or{} + for _, child := range e { + cond, err := exprSQL(child) + if err != nil { + return nil, err + } + or = append(or, cond) + } + return or, nil + case criteria.Is: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Eq(fields) + }, false) + case criteria.IsNot: + return isNotExpr(e) + case criteria.Gt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Lt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.Before: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.After: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Contains: + return likeExpr(e, "%%%v%%", false) + case criteria.NotContains: + return likeExpr(e, "%%%v%%", true) + case criteria.StartsWith: + return likeExpr(e, "%v%%", false) + case criteria.EndsWith: + return likeExpr(e, "%%%v", false) + case criteria.InTheRange: + return rangeExpr(e) + case criteria.InTheLast: + return periodExpr(e, false) + case criteria.NotInTheLast: + return periodExpr(e, true) + case criteria.InPlaylist: + return inList(e, false) + case criteria.NotInPlaylist: + return inList(e, true) + default: + return nil, fmt.Errorf("unknown criteria expression type %T", expr) + } +} + +func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Eq{"value": value}, true), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return squirrel.NotEq(fields), nil +} + +func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return makeCond(fields), nil +} + +func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + if negate { + lk := squirrel.NotLike{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil + } + lk := squirrel.Like{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil +} + +func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + and := squirrel.And{} + for field, value := range fields { + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) + } + and = append(and, + squirrel.GtOrEq{field: s.Index(0).Interface()}, + squirrel.LtOrEq{field: s.Index(1).Interface()}, + ) + } + return and, nil +} + +func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + var field string + var value any + for f, v := range fields { + field, value = f, v + break + } + days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64) + if err != nil { + return nil, err + } + firstDate := startOfPeriod(days, time.Now()) + if negate { + return squirrel.Or{ + squirrel.Lt{field: firstDate}, + squirrel.Eq{field: nil}, + }, nil + } + return squirrel.Gt{field: firstDate}, nil +} + +func startOfPeriod(numDays int64, from time.Time) string { + return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") +} + +func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { + playlistID, ok := values["id"].(string) + if !ok { + return nil, errors.New("playlist id not given") + } + subQuery := squirrel.Select("media_file_id"). + From("playlist_tracks pl"). + LeftJoin("playlist on pl.playlist_id = playlist.id"). + Where(squirrel.And{ + squirrel.Eq{"pl.playlist_id": playlistID}, + squirrel.Eq{"playlist.public": 1}, + }) + subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + return nil, err + } + if negate { + return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil + } + return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil +} + +func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + if info.IsRole { + return roleCond{role: info.Name, cond: cond, not: negate} + } + return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} +} + +type tagCond struct { + tag string + numeric bool + cond squirrel.Sqlizer + not bool +} + +func (e tagCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +type roleCond struct { + role string + cond squirrel.Sqlizer + not bool +} + +func (e roleCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) { + if len(values) != 1 { + return "", nil, criteria.FieldInfo{}, false + } + for field, value := range values { + info, ok := criteria.LookupField(field) + return field, value, info, ok + } + return "", nil, criteria.FieldInfo{}, false +} + +func sqlFields[T ~map[string]any](values T) (map[string]any, error) { + fields := make(map[string]any, len(values)) + for field, value := range values { + info, ok := criteria.LookupField(field) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) + } + sqlField, ok := fieldExpr(info.Name) + if !ok || sqlField == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + fields[sqlField] = value + } + return fields, nil +} + +func fieldExpr(name string) (string, bool) { + field, ok := smartPlaylistFields[strings.ToLower(name)] + return field.expr, ok +} + +func fieldJoinType(name string) smartPlaylistJoinType { + info, ok := criteria.LookupField(name) + if !ok { + return smartPlaylistJoinNone + } + field, ok := smartPlaylistFields[info.Name] + if !ok { + return smartPlaylistJoinNone + } + return field.joinType +} + +func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { + var joins smartPlaylistJoinType + _ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { + for field := range criteria.Fields(expr) { + joins |= fieldJoinType(field) + } + return nil + }) + return joins +} + +func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { + joins := c.ExpressionJoins() + for _, sortField := range sortFields(c.criteria.Sort) { + joins |= fieldJoinType(sortField) + } + return joins +} + +func (c smartPlaylistCriteria) OrderBy() string { + sortValue := c.criteria.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.criteria.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + dir := "asc" + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + if strings.HasPrefix(part, "-") { + dir = "desc" + } + part = strings.TrimSpace(part[1:]) + } + sortField := strings.ToLower(part) + mapped, ok := sortExpr(sortField) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", sortField) + continue + } + if order == "desc" { + if dir == "asc" { + dir = "desc" + } else { + dir = "asc" + } + } + fields = append(fields, mapped+" "+dir) + } + return strings.Join(fields, ", ") +} + +func sortFields(sortValue string) []string { + if sortValue == "" { + sortValue = "title" + } + parts := strings.Split(sortValue, ",") + fields := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-")) + if part != "" { + fields = append(fields, part) + } + } + return fields +} + +func sortExpr(sortField string) (string, bool) { + info, ok := criteria.LookupField(sortField) + if !ok { + return "", false + } + if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + return field.order, true + } + var mapped string + switch { + case info.IsTag: + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + case info.IsRole: + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + default: + field, ok := smartPlaylistFields[info.Name] + if !ok || field.expr == "" { + return "", false + } + mapped = field.expr + } + if info.Numeric { + mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) + } + return mapped, true +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go new file mode 100644 index 000000000..9fa5ff104 --- /dev/null +++ b/persistence/criteria_sql_test.go @@ -0,0 +1,170 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/model/criteria" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart playlist criteria SQL", func() { + BeforeEach(func() { + criteria.AddRoles([]string{"artist", "composer", "producer"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) + criteria.AddNumericTags([]string{"rate"}) + }) + + DescribeTable("expressions", + func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal(expectedSQL)) + Expect(args).To(HaveExactElements(expectedArgs...)) + }, + Entry("all group", + criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, + "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + Entry("any group", + criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, + "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), + Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), + Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), + Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), + Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), + Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), + Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), + Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), + Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), + Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + ) + + It("builds relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("annotation.play_date > ?")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("builds negated relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("returns an error for unknown fields", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where() + + Expect(err).To(MatchError("invalid field in criteria: unknown")) + }) + + Describe("sort", func() { + It("sorts by regular fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + + It("sorts by tag fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) + }) + + It("sorts by role fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) + }) + + It("casts numeric tags when sorting", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) + }) + + It("sorts by albumtype alias", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) + }) + + It("sorts by random", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) + }) + + It("sorts by multiple fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) + }) + + It("reverts order when order is desc", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) + }) + + It("ignores invalid sort fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + }) + + It("has SQL mappings for all non-tag/non-role criteria fields", func() { + for _, name := range criteria.AllFieldNames() { + info, ok := criteria.LookupField(name) + Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name) + if info.IsTag || info.IsRole { + continue + } + _, hasSQLField := smartPlaylistFields[info.Name] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) + } + }) + + Describe("joins", func() { + It("excludes sort-only joins from expression joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} + cSQL := newSmartPlaylistCriteria(c) + + Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("includes expression-based joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} + + Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("detects nested album and artist joins", func() { + c := criteria.Criteria{Expression: criteria.All{ + criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}}, + criteria.Any{criteria.Gt{"artistPlayCount": 10}}, + }} + + joins := newSmartPlaylistCriteria(c).RequiredJoins() + Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + + It("detects join types from sort fields with direction prefixes", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"} + + Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + }) +}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 8d1bbe0f8..6bf3ded30 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -228,6 +228,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Re-populate playlist based on Smart Playlist criteria rules := *pls.Rules + rulesSQL := newSmartPlaylistCriteria(rules) // If the playlist depends on other playlists, recursively refresh them first childPlaylistIds := rules.ChildPlaylistIds() @@ -240,14 +241,15 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { r.refreshSmartPlaylist(childPls) } - sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + orderBy := rulesSQL.OrderBy() + sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file").LeftJoin("annotation on ("+ "annotation.item_id = media_file.id"+ " AND annotation.item_type = 'media_file'"+ " AND annotation.user_id = ?)", usr.ID) // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rules.RequiredJoins() + requiredJoins := rulesSQL.RequiredJoins() sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) // Only include media files from libraries the user has access to @@ -256,7 +258,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Resolve percentage-based limit to an absolute number before applying criteria if rules.IsPercentageLimit() { // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rules.ExpressionJoins() + exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file"). LeftJoin("annotation on ("+ "annotation.item_id = media_file.id"+ @@ -264,7 +266,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { " AND annotation.user_id = ?)", usr.ID) countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) countSq = r.applyLibraryFilter(countSq, "media_file") - countSq = countSq.Where(rules) + cond, err := rulesSQL.Where() + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + countSq = countSq.Where(cond) var res struct{ Count int64 } err = r.queryOne(countSq, &res) @@ -279,7 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } // Apply the criteria rules - sq = r.addCriteria(sq, rules) + sq, err = r.addCriteria(sq, rules) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) _, err = r.executeSQL(insSql) if err != nil { @@ -310,14 +321,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return true } -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder { - if joins.Has(criteria.JoinAlbumAnnotation) { +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { sq = sq.LeftJoin("annotation AS album_annotation ON ("+ "album_annotation.item_id = media_file.album_id"+ " AND album_annotation.item_type = 'album'"+ " AND album_annotation.user_id = ?)", userID) } - if joins.Has(criteria.JoinArtistAnnotation) { + if joins.has(smartPlaylistJoinArtistAnnotation) { sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ "artist_annotation.item_id = media_file.artist_id"+ " AND artist_annotation.item_type = 'artist'"+ @@ -326,15 +337,20 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j return sq } -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { - sql = sql.Where(c) +func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) { + cSQL := newSmartPlaylistCriteria(c) + cond, err := cSQL.Where() + if err != nil { + return sql, err + } + sql = sql.Where(cond) if c.Limit > 0 { sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) } - if order := c.OrderBy(); order != "" { + if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) } - return sql + return sql, nil } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { From ca09070a6cbda9d72f426d2862ec7dd934abf2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 25 Apr 2026 14:59:06 -0400 Subject: [PATCH 03/28] feat(smartplaylists): relax playlist visibility in inPlaylist/notInPlaylist rules (#5411) * test(e2e): add end-to-end tests for smart playlists functionality Signed-off-by: Deluan * fix: enforce playlist visibility in smart playlist InPlaylist/NotInPlaylist rules Previously, the InPlaylist/NotInPlaylist smart playlist criteria only allowed referencing public playlists, regardless of who owned the smart playlist. This was too restrictive for owners referencing their own private playlists and for admins who should have unrestricted access. The fix passes the smart playlist owner's identity and admin status into the criteria SQL builder, so that: admins can reference any playlist, regular users can reference public playlists plus their own private ones, and inaccessible referenced playlists produce a warning instead of a hard error. Also prevents recursive refresh of child playlists the owner cannot access. * test(e2e): clarify user roles and fix playlist visibility tests Renamed testUser/otherUser to adminUser/regularUser to make the admin vs regular user distinction explicit in test code. Fixed three playlist visibility tests that were evaluating as admin (bypassing all access checks) instead of as a regular user, so the public playlist path is now actually exercised. All playlist operator tests now use explicit evaluateRuleAs calls with the appropriate user role. * fix: sync rulesSQL criteria after limitPercent resolution The rulesSQL struct captures a copy of rules at creation time. When limitPercent is resolved later, rules.Limit is updated but rulesSQL still holds the stale value. This caused percentage-based smart playlist limits to be silently ignored. Fix by updating rulesSQL.criteria after the resolution. * refactor: convert inList to a method on smartPlaylistCriteria The inList function already receives ownerID and ownerIsAdmin from the smartPlaylistCriteria caller. Making it a method lets it access those fields directly from the receiver, simplifying the signature and staying consistent with exprSQL which was already converted to a method. * refactor: simplify function signatures by removing type parameters in criteria_sql.go Signed-off-by: Deluan --------- Signed-off-by: Deluan --- persistence/criteria_sql.go | 65 ++++++++++------ persistence/criteria_sql_test.go | 28 +++++++ .../e2e/e2e_suite_test.go | 59 ++++++++++----- .../e2e/smartplaylist_test.go | 74 ++++++++++++++----- persistence/playlist_repository.go | 17 +++-- 5 files changed, 176 insertions(+), 67 deletions(-) rename {core/playlists => persistence}/e2e/e2e_suite_test.go (85%) rename {core/playlists => persistence}/e2e/smartplaylist_test.go (76%) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 2f7b73885..c75f6467b 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -8,7 +8,7 @@ import ( "strings" "time" - squirrel "github.com/Masterminds/squirrel" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) @@ -32,11 +32,24 @@ type smartPlaylistField struct { } type smartPlaylistCriteria struct { - criteria criteria.Criteria + criteria criteria.Criteria + ownerID string + ownerIsAdmin bool } -func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria { - return smartPlaylistCriteria{criteria: c} +func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { + cSQL := smartPlaylistCriteria{criteria: c} + for _, opt := range opts { + opt(&cSQL) + } + return cSQL +} + +func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { + return func(c *smartPlaylistCriteria) { + c.ownerID = ownerID + c.ownerIsAdmin = ownerIsAdmin + } } var smartPlaylistFields = map[string]smartPlaylistField{ @@ -109,15 +122,15 @@ func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { if c.criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } - return exprSQL(c.criteria.Expression) + return c.exprSQL(c.criteria.Expression) } -func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { switch e := expr.(type) { case criteria.All: and := squirrel.And{} for _, child := range e { - cond, err := exprSQL(child) + cond, err := c.exprSQL(child) if err != nil { return nil, err } @@ -127,7 +140,7 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { case criteria.Any: or := squirrel.Or{} for _, child := range e { - cond, err := exprSQL(child) + cond, err := c.exprSQL(child) if err != nil { return nil, err } @@ -171,15 +184,15 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { case criteria.NotInTheLast: return periodExpr(e, true) case criteria.InPlaylist: - return inList(e, false) + return c.inList(e, false) case criteria.NotInPlaylist: - return inList(e, true) + return c.inList(e, true) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } } -func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { +func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Eq{"value": value}, true), nil } @@ -190,7 +203,7 @@ func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } -func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { +func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil } @@ -201,7 +214,7 @@ func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel return makeCond(fields), nil } -func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) { +func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil } @@ -223,7 +236,7 @@ func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirre return lk, nil } -func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { +func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { fields, err := sqlFields(values) if err != nil { return nil, err @@ -242,7 +255,7 @@ func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { return and, nil } -func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { +func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { fields, err := sqlFields(values) if err != nil { return nil, err @@ -271,18 +284,26 @@ func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } -func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { playlistID, ok := values["id"].(string) if !ok { return nil, errors.New("playlist id not given") } + filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + if !c.ownerIsAdmin { + if c.ownerID == "" { + filters = append(filters, squirrel.Eq{"playlist.public": 1}) + } else { + filters = append(filters, squirrel.Or{ + squirrel.Eq{"playlist.public": 1}, + squirrel.Eq{"playlist.owner_id": c.ownerID}, + }) + } + } subQuery := squirrel.Select("media_file_id"). From("playlist_tracks pl"). LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistID}, - squirrel.Eq{"playlist.public": 1}, - }) + Where(filters) subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() if err != nil { return nil, err @@ -334,7 +355,7 @@ func (e roleCond) ToSql() (string, []any, error) { return cond, args, err } -func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) { +func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { if len(values) != 1 { return "", nil, criteria.FieldInfo{}, false } @@ -345,7 +366,7 @@ func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, return "", nil, criteria.FieldInfo{}, false } -func sqlFields[T ~map[string]any](values T) (map[string]any, error) { +func sqlFields(values map[string]any) (map[string]any, error) { fields := make(map[string]any, len(values)) for field, value := range values { info, ok := criteria.LookupField(field) diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 9fa5ff104..e06a4eccb 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,34 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), ) + Describe("playlist permissions", func() { + It("allows public or same-owner playlist references for regular users", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner("owner-id", false), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id")) + }) + + It("allows all playlist references for admins", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner("admin-id", true), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef")) + }) + }) + It("builds relative date expressions", func() { sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() Expect(err).ToNot(HaveOccurred()) diff --git a/core/playlists/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go similarity index 85% rename from core/playlists/e2e/e2e_suite_test.go rename to persistence/e2e/e2e_suite_test.go index e9a717d0a..ea0d0fea8 100644 --- a/core/playlists/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -53,14 +53,14 @@ var ( snapshotPath string snapshotTables []string - testUser = model.User{ + adminUser = model.User{ ID: "sp-test-user-1", UserName: "sptestuser", Name: "SP Test User", IsAdmin: true, } - otherUser = model.User{ + regularUser = model.User{ ID: "sp-test-user-2", UserName: "spotheruser", Name: "SP Other User", @@ -147,25 +147,36 @@ func findMediaFileByTitle(title string) string { } func evaluateRule(jsonRule string) []string { - titles := evaluateRuleOrdered(jsonRule) + titles := evaluateRuleOrderedAs(adminUser, jsonRule) sort.Strings(titles) return titles } func evaluateRuleOrdered(jsonRule string) []string { + return evaluateRuleOrderedAs(adminUser, jsonRule) +} + +func evaluateRuleAs(owner model.User, jsonRule string) []string { + titles := evaluateRuleOrderedAs(owner, jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string { + userCtx := request.WithUser(GinkgoT().Context(), owner) var rules criteria.Criteria err := json.Unmarshal([]byte(jsonRule), &rules) Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) pls := &model.Playlist{ Name: "test-smart-playlist", - OwnerID: testUser.ID, + OwnerID: owner.ID, Rules: &rules, } - err = ds.Playlist(ctx).Put(pls) + err = ds.Playlist(userCtx).Put(pls) Expect(err).ToNot(HaveOccurred()) - loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) titles := make([]string, len(loaded.Tracks)) @@ -198,12 +209,20 @@ func createPrivatePlaylist(owner model.User, titles ...string) string { } func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, true, jsonRule) +} + +func createPrivateSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, false, jsonRule) +} + +func createSmartPlaylist(owner model.User, public bool, jsonRule string) string { var rules criteria.Criteria Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) pls := &model.Playlist{ Name: "ref-smart-playlist", OwnerID: owner.ID, - Public: true, + Public: public, Rules: &rules, } Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) @@ -211,7 +230,7 @@ func createPublicSmartPlaylist(owner model.User, jsonRule string) string { } var _ = BeforeSuite(func() { - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) tmpDir := GinkgoT().TempDir() dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") @@ -226,28 +245,28 @@ var _ = BeforeSuite(func() { initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - userWithPass := testUser + userWithPass := adminUser userWithPass.NewPassword = "password" Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) - otherUserWithPass := otherUser - otherUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed()) + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName) + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) Expect(err).ToNot(HaveOccurred()) - testUser.Libraries = loadedUser.Libraries + adminUser.Libraries = loadedUser.Libraries - loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName) + loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName) Expect(err).ToNot(HaveOccurred()) - otherUser.Libraries = loadedOther.Libraries + regularUser.Libraries = loadedOther.Libraries - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), @@ -315,7 +334,7 @@ func restoreDB() { } func setupTestDB() { - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) DeferCleanup(configtest.SetupConfig()) conf.Server.MusicFolder = "fake:///music" conf.Server.DevExternalScanner = false diff --git a/core/playlists/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go similarity index 76% rename from core/playlists/e2e/smartplaylist_test.go rename to persistence/e2e/smartplaylist_test.go index 6e31c6787..658030de5 100644 --- a/core/playlists/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -1,8 +1,10 @@ package e2e import ( + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" ) var _ = Describe("Smart Playlists", func() { @@ -245,46 +247,82 @@ var _ = Describe("Smart Playlists", func() { Describe("Playlist operators", func() { It("matches tracks in a public regular playlist", func() { - refID := createPublicPlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Come Together", "So What")) }) It("matches tracks not in a public regular playlist", func() { - refID := createPublicPlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`) + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) }) It("recursively refreshes a referenced smart playlist owned by the same user", func() { - smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) Expect(results).To(ConsistOf("So What")) }) It("does not refresh a referenced smart playlist owned by another user", func() { - smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) Expect(results).To(BeEmpty()) }) - It("does not match tracks from a private playlist", func() { - refID := createPrivatePlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) - Expect(results).To(BeEmpty()) + It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() { + smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) + + loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedA.Tracks).To(BeEmpty()) + Expect(loadedA.EvaluatedAt).To(BeNil()) + + loadedB, err := ds.Playlist(ctx).Get(smartBID) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedB.EvaluatedAt).To(BeNil()) }) - It("matches tracks in a public playlist owned by another user", func() { - refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + It("matches tracks from a private playlist owned by the same user", func() { + refID := createPrivatePlaylist(regularUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("allows admin-owned smart playlists to reference private playlists owned by other users", func() { + refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody") + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Bohemian Rhapsody")) }) - It("does not match tracks from a private playlist owned by another user", func() { - refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + It("does not match tracks from a private playlist owned by another regular user", func() { + refID := createPrivatePlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(BeEmpty()) }) + + It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() { + hook, cleanup := tests.LogHook() + defer cleanup() + + refID := createPrivatePlaylist(adminUser, "Come Together") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + + Expect(hook.LastEntry()).ToNot(BeNil()) + Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) + Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner")) + Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID)) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 6bf3ded30..11e76fa8b 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/pocketbase/dbx" ) @@ -228,13 +227,17 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Re-populate playlist based on Smart Playlist criteria rules := *pls.Rules - rulesSQL := newSmartPlaylistCriteria(rules) + rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin)) // If the playlist depends on other playlists, recursively refresh them first childPlaylistIds := rules.ChildPlaylistIds() for _, id := range childPlaylistIds { childPls, err := r.Get(id) if err != nil { + if errors.Is(err, model.ErrNotFound) { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + continue + } log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) return false } @@ -283,10 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) rules.Limit = resolvedLimit rules.LimitPercent = 0 + rulesSQL.criteria = rules } // Apply the criteria rules - sq, err = r.addCriteria(sq, rules) + sq, err = r.addCriteria(sq, rulesSQL) if err != nil { log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return false @@ -337,15 +341,14 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j return sq } -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) { - cSQL := newSmartPlaylistCriteria(c) +func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { cond, err := cSQL.Where() if err != nil { return sql, err } sql = sql.Where(cond) - if c.Limit > 0 { - sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) + if cSQL.criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset)) } if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) From 9824102efb5ec05990f85886e3bea65353653415 Mon Sep 17 00:00:00 2001 From: Daniele Massa <90097496+DanieleMassa@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:18:51 +0200 Subject: [PATCH 04/28] fix(ui): completed Italian translation (#5407) Co-authored-by: Daniele Massa --- resources/i18n/it.json | 459 ++++++++++++++++++++++++++++++++--------- 1 file changed, 362 insertions(+), 97 deletions(-) diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 11fadb46b..b91c04064 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -10,32 +10,48 @@ "playCount": "Riproduzioni", "title": "Titolo", "artist": "Artista", + "composer": "Compositore", "album": "Album", "path": "Percorso", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", "size": "Dimensioni", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", - "discSubtitle": "Sottotitoli disco", + "bitDepth": "Profondità di bit", + "sampleRate": "Frequenza di campionamento", + "albumGain": "Guadagno album", + "trackGain": "Guadagno traccia", + "channels": "Canali", + "disc": "Disco %{discNumber}", + "discSubtitle": "Sottotitolo disco", "starred": "Preferita", "comment": "Commento", "rating": "Valutazione", "quality": "Qualità", "bpm": "BPM", "playDate": "Ultima riproduzione", - "channels": "Canali", - "createdAt": "" + "createdAt": "Data di aggiunta", + "grouping": "Raggruppamento", + "mood": "Umore", + "participants": "Partecipanti aggiuntivi", + "tags": "Tag aggiuntivi", + "mappedTags": "Tag mappati", + "rawTags": "Tag grezzi", + "missing": "Mancante" }, "actions": { "addToQueue": "Aggiungi alla coda", "playNow": "Riproduci adesso", "addToPlaylist": "Aggiungi alla playlist", + "showInPlaylist": "Mostra nella playlist", "shuffleAll": "Riproduci casualmente", "download": "Scarica", "playNext": "Riproduci come successivo", - "info": "Informazioni" + "info": "Informazioni", + "instantMix": "Mix istantaneo" } }, "album": { @@ -46,29 +62,38 @@ "duration": "Durata", "songCount": "Tracce", "playCount": "Riproduzioni", + "size": "Dimensione", "name": "Nome", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", + "date": "Data di registrazione", + "originalDate": "Originale", + "releaseDate": "Data di pubblicazione", + "releases": "Pubblicazione |||| Pubblicazioni", + "released": "Pubblicato", "updatedAt": "Ultimo aggiornamento", "comment": "Commento", "rating": "Valutazione", - "createdAt": "Data di creazione", - "size": "Dimensione", - "originalDate": "", - "releaseDate": "Data di pubblicazione", - "releases": "Pubblicazione |||| Pubblicazioni", - "released": "Pubblicato" + "createdAt": "Data di aggiunta", + "recordLabel": "Etichetta", + "catalogNum": "Numero di catalogo", + "releaseType": "Tipo", + "grouping": "Raggruppamento", + "media": "Media", + "mood": "Umore", + "missing": "Mancante" }, "actions": { "playAll": "Riproduci", "playNext": "Riproduci come successivo", "addToQueue": "Aggiungi alla coda", + "share": "Condividi", "shuffle": "Riproduci casualmente", - "addToPlaylist": "Aggiungi alla Playlist", + "addToPlaylist": "Aggiungi alla playlist", "download": "Scarica", - "info": "Informazioni", - "share": "Condividi" + "info": "Informazioni" }, "lists": { "all": "Tutti", @@ -86,10 +111,33 @@ "name": "Nome", "albumCount": "Album", "songCount": "Numero tracce", + "size": "Dimensione", "playCount": "Riproduzioni", "rating": "Valutazione", "genre": "Genere", - "size": "Dimensione" + "role": "Ruolo", + "missing": "Mancante" + }, + "roles": { + "albumartist": "Artista Album |||| Artisti Album", + "artist": "Artista |||| Artisti", + "composer": "Compositore |||| Compositori", + "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", + "lyricist": "Paroliere |||| Parolieri", + "arranger": "Arrangiatore |||| Arrangiatori", + "producer": "Produttore |||| Produttori", + "director": "Direttore |||| Direttori", + "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "mixer": "Mixer |||| Mixer", + "remixer": "Remixer |||| Remixer", + "djmixer": "DJ Mixer |||| DJ Mixer", + "performer": "Esecutore |||| Esecutori", + "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" + }, + "actions": { + "topSongs": "Brani più ascoltati", + "shuffle": "Riproduci casualmente", + "radio": "Radio" } }, "user": { @@ -97,31 +145,39 @@ "fields": { "userName": "Nome utente", "isAdmin": "Amministratore", - "lastLoginAt": "Ultimo accesso", + "lastLoginAt": "Ultimo login", + "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", "name": "Nome", "password": "Password", - "createdAt": "Creato a", + "createdAt": "Creato il", "changePassword": "Cambiare la password?", "currentPassword": "Password Attuale", "newPassword": "Nuova Password", - "token": "Token" + "token": "Token", + "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso" + "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { "created": "Utente creato", "updated": "Utente aggiornato", "deleted": "Utente eliminato" }, + "validation": { + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", - "clickHereForToken": "Clicca qui per ottenere il tuo token" + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "clickHereForToken": "Clicca qui per ottenere il tuo token", + "selectAllLibraries": "Seleziona tutte le librerie", + "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" } }, "player": { - "name": "Client |||| Client", + "name": "Lettore |||| Lettori", "fields": { "name": "Nome", "transcodingId": "Transcodifica", @@ -130,7 +186,7 @@ "userName": "Nome utente", "lastSeen": "Ultimo accesso", "reportRealPath": "Mostra percorso reale", - "scrobbleEnabled": "" + "scrobbleEnabled": "Invia scrobble ai servizi esterni" } }, "transcoding": { @@ -157,45 +213,203 @@ "path": "Importa da" }, "actions": { - "selectPlaylist": "Aggiungi tracce alla playlist:", - "addNewPlaylist": "Aggiungi \"%{name}\"", + "selectPlaylist": "Seleziona una playlist:", + "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", + "saveQueue": "Salva la coda nella playlist", "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata" + "makePrivate": "Rendi Privata", + "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", + "pressEnterToCreate": "Premi Invio per creare una nuova playlist", + "removeFromSelection": "Rimuovi dalla selezione" }, "message": { "duplicate_song": "Aggiungere i duplicati", - "song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?" + "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", + "noPlaylistsFound": "Nessuna playlist trovata", + "noPlaylists": "Nessuna playlist disponibile" } }, "radio": { "name": "Radio |||| Radio", "fields": { "name": "Nome", - "streamUrl": "", - "homePageUrl": "", - "updatedAt": "", - "createdAt": "" + "streamUrl": "URL dello stream", + "homePageUrl": "URL della pagina web", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" }, "actions": { - "playNow": "" + "playNow": "Riproduci adesso" } }, "share": { - "name": "", + "name": "Condivisione |||| Condivisioni", "fields": { - "username": "", - "url": "", - "description": "", - "contents": "", - "expiresAt": "", - "lastVisitedAt": "", - "visitCount": "", - "format": "", - "maxBitRate": "", - "updatedAt": "", - "createdAt": "", - "downloadable": "" + "username": "Condiviso da", + "url": "URL", + "description": "Descrizione", + "downloadable": "Consenti i download?", + "contents": "Contenuti", + "expiresAt": "Scade il", + "lastVisitedAt": "Ultima visita", + "visitCount": "Visite", + "format": "Formato", + "maxBitRate": "Bitrate massimo", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "File mancante |||| File mancanti", + "empty": "Nessun file mancante", + "fields": { + "path": "Percorso", + "size": "Dimensione", + "libraryName": "Libreria", + "updatedAt": "Scomparso il" + }, + "actions": { + "remove": "Rimuovi", + "remove_all": "Rimuovi tutti" + }, + "notifications": { + "removed": "File mancanti rimossi" + } + }, + "library": { + "name": "Libreria |||| Librerie", + "fields": { + "name": "Nome", + "path": "Percorso", + "remotePath": "Percorso remoto", + "lastScanAt": "Ultima scansione", + "songCount": "Tracce", + "albumCount": "Album", + "artistCount": "Artisti", + "totalSongs": "Tracce", + "totalAlbums": "Album", + "totalArtists": "Artisti", + "totalFolders": "Cartelle", + "totalFiles": "File", + "totalMissingFiles": "File mancanti", + "totalSize": "Dimensione totale", + "totalDuration": "Durata", + "defaultNewUsers": "Predefinita per i nuovi utenti", + "createdAt": "Creata il", + "updatedAt": "Aggiornata il" + }, + "sections": { + "basic": "Informazioni di base", + "statistics": "Statistiche" + }, + "actions": { + "scan": "Scansiona la libreria", + "quickScan": "Scansione rapida", + "fullScan": "Scansione completa", + "manageUsers": "Gestisci accesso utenti", + "viewDetails": "Visualizza dettagli" + }, + "notifications": { + "created": "Libreria creata con successo", + "updated": "Libreria aggiornata con successo", + "deleted": "Libreria eliminata con successo", + "scanStarted": "Scansione della libreria avviata", + "quickScanStarted": "Scansione rapida avviata", + "fullScanStarted": "Scansione completa avviata", + "scanError": "Errore durante l'avvio della scansione. Controlla i log", + "scanCompleted": "Scansione della libreria completata" + }, + "validation": { + "nameRequired": "Il nome della libreria è obbligatorio", + "pathRequired": "Il percorso della libreria è obbligatorio", + "pathNotDirectory": "Il percorso della libreria deve essere una directory", + "pathNotFound": "Percorso della libreria non trovato", + "pathNotAccessible": "Il percorso della libreria non è accessibile", + "pathInvalid": "Percorso della libreria non valido" + }, + "messages": { + "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "scanInProgress": "Scansione in corso...", + "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" + } + }, + "plugin": { + "name": "Plugin |||| Plugin", + "fields": { + "id": "ID", + "name": "Nome", + "description": "Descrizione", + "version": "Versione", + "author": "Autore", + "website": "Sito web", + "permissions": "Permessi", + "enabled": "Abilitato", + "status": "Stato", + "path": "Percorso", + "lastError": "Errore", + "hasError": "Errore", + "updatedAt": "Aggiornato il", + "createdAt": "Installato il", + "configKey": "Chiave", + "configValue": "Valore", + "allUsers": "Consenti tutti gli utenti", + "selectedUsers": "Utenti selezionati", + "allLibraries": "Consenti tutte le librerie", + "selectedLibraries": "Librerie selezionate", + "allowWriteAccess": "Consenti accesso in scrittura" + }, + "sections": { + "status": "Stato", + "info": "Informazioni sul plugin", + "configuration": "Configurazione", + "manifest": "Manifest", + "usersPermission": "Permessi utenti", + "libraryPermission": "Permesso libreria" + }, + "status": { + "enabled": "Abilitato", + "disabled": "Disabilitato" + }, + "actions": { + "enable": "Abilita", + "disable": "Disabilita", + "disabledDueToError": "Correggi l'errore prima di abilitare", + "disabledUsersRequired": "Seleziona gli utenti prima di abilitare", + "disabledLibrariesRequired": "Seleziona le librerie prima di abilitare", + "addConfig": "Aggiungi configurazione", + "rescan": "Riscansiona" + }, + "notifications": { + "enabled": "Plugin abilitato", + "disabled": "Plugin disabilitato", + "updated": "Plugin aggiornato", + "error": "Errore durante l'aggiornamento del plugin" + }, + "validation": { + "invalidJson": "La configurazione deve essere un JSON valido" + }, + "messages": { + "configHelp": "Configura il plugin usando coppie chiave-valore. Lascia vuoto se il plugin non richiede configurazione.", + "configValidationError": "Validazione della configurazione fallita:", + "schemaRenderError": "Impossibile visualizzare il modulo di configurazione. Lo schema del plugin potrebbe non essere valido.", + "clickPermissions": "Clicca su un permesso per i dettagli", + "noConfig": "Nessuna configurazione impostata", + "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", + "noUsers": "Nessun utente selezionato", + "permissionReason": "Motivo", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", + "noLibraries": "Nessuna libreria selezionata", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", + "requiredHosts": "Host richiesti" + }, + "placeholders": { + "configKey": "chiave", + "configValue": "valore" } } }, @@ -206,12 +420,13 @@ "confirmPassword": "Conferma la password", "buttonCreateAdmin": "Crea amministratore", "auth_check_error": "Per favore accedi per continuare", - "user_menu": "Profile", + "user_menu": "Profilo", "username": "Nome utente", "password": "Password", "sign_in": "Accedi", "sign_in_error": "Autenticazione fallita, per favore riprova", - "logout": "Disconnetti" + "logout": "Disconnetti", + "insightsCollectionNote": "Navidrome raccoglie dati di utilizzo anonimi per\nmigliorare il progetto. Clicca [qui] per saperne di più\ne per disattivarlo se lo desideri" }, "validation": { "invalidChars": "Per favore usa solo lettere e numeri", @@ -226,13 +441,14 @@ "oneOf": "Deve essere uno di: %{options}", "regex": "Deve rispettare il formato (espressione regolare): %{pattern}", "unique": "Deve essere unico", - "url": "" + "url": "Deve essere un URL valido" }, "action": { "add_filter": "Aggiungi un filtro", "add": "Aggiungi", "back": "Indietro", "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Annulla", "clear_input_value": "Cancella", "clone": "Duplica", @@ -244,7 +460,7 @@ "list": "Elenco", "refresh": "Aggiorna", "remove_filter": "Rimuovi questo filtro", - "remove": "Remove", + "remove": "Rimuovi", "save": "Salva", "search": "Cerca", "show": "Mostra", @@ -255,17 +471,16 @@ "open_menu": "Apri menù", "close_menu": "Chiudi menù", "unselect": "Deseleziona", - "skip": "Saltare i duplicati", - "bulk_actions_mobile": "", - "share": "", - "download": "" + "skip": "Salta", + "share": "Condividi", + "download": "Scarica" }, "boolean": { - "true": "Si", + "true": "Sì", "false": "No" }, "page": { - "create": "Aggiungi %{name}", + "create": "Crea %{name}", "dashboard": "Pannello di controllo", "edit": "%{name} #%{id}", "error": "Qualcosa è andato storto", @@ -274,7 +489,7 @@ "not_found": "Non trovato", "show": "%{name} #%{id}", "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi invitare un amico?" + "invite": "Vuoi aggiungerne uno?" }, "input": { "file": { @@ -308,17 +523,17 @@ "loading": "La pagina si sta caricando, solo un momento per favore", "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", - "yes": "Si", - "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?" + "yes": "Sì", + "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" }, "navigation": { "no_results": "Nessun risultato trovato", "no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.", - "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall’intervallo", - "page_out_from_end": "Non è possibile andare oltre l’ultima pagina", + "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall'intervallo", + "page_out_from_end": "Non è possibile andare oltre l'ultima pagina", "page_out_from_begin": "Non è possibile andare prima della prima pagina", "page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}", - "page_rows_per_page": "Righe per pagina:", + "page_rows_per_page": "Elementi per pagina:", "next": "Successivo", "prev": "Precedente", "skip_nav": "Passa al contenuto" @@ -334,7 +549,7 @@ "i18n_error": "Impossibile caricare la traduzione per la lingua selezionata", "canceled": "Azione annullata", "logged_out": "La sessione è scaduta, per favore accedi di nuovo.", - "new_version": "Una nuova versione è disponibile! Ricarica la pagina" + "new_version": "Una nuova versione è disponibile! Ricarica la pagina." }, "toggleFieldsMenu": { "columnsToDisplay": "Colonne da mostrare", @@ -344,39 +559,58 @@ } }, "message": { - "note": "Note", - "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.", - "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", + "uploadCover": "Carica copertina", + "removeCover": "Rimuovi copertina", + "coverUploaded": "Copertina aggiornata", + "coverRemoved": "Copertina rimossa", + "coverUploadError": "Errore durante il caricamento della copertina", + "coverRemoveError": "Errore durante la rimozione della copertina", + "note": "NOTA", + "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l'interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l'opzione %{config}.", + "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi di sistema dalle impostazioni di transcodifica tramite l'interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", "songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist", - "noPlaylistsAvailable": "Nessuna playlist", + "noSimilarSongsFound": "Nessuna traccia simile trovata", + "startingInstantMix": "Caricamento del Mix istantaneo...", + "noTopSongsFound": "Nessun brano più ascoltato trovato", + "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "remove_missing_title": "Rimuovi i file mancanti", + "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_title": "Rimuovi tutti i file mancanti", + "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", - "lastfmLinkFailure": "Non è stato possible collegare Last.fm", + "lastfmLinkFailure": "Non è stato possibile collegare Last.fm", "lastfmUnlinkSuccess": "Lo scrobbling è stato disabilitato e Last.fm è stato disconnesso", "lastfmUnlinkFailure": "Non è stato possibile scollegare Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente: %{user}", + "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz disconnesso e scrobbling disabilitato", + "listenBrainzUnlinkFailure": "Non è stato possibile disconnettere ListenBrainz", "openIn": { "lastfm": "Apri in Last.fm", "musicbrainz": "Apri in MusicBrainz" }, "lastfmLink": "Per saperne di più...", - "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente %{user}", - "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "", - "listenBrainzUnlinkFailure": "", - "downloadOriginalFormat": "", - "shareOriginalFormat": "", - "shareDialogTitle": "", - "shareBatchDialogTitle": "", - "shareSuccess": "", - "shareFailure": "", - "downloadDialogTitle": "", - "shareCopyToClipboard": "" + "shareOriginalFormat": "Condividi nel formato originale", + "shareDialogTitle": "Condividi %{resource} '%{name}'", + "shareBatchDialogTitle": "Condividi 1 %{resource} |||| Condividi %{smart_count} %{resource}", + "shareCopyToClipboard": "Copia negli appunti: Ctrl+C, Invio", + "shareSuccess": "URL copiato negli appunti: %{url}", + "shareFailure": "Errore durante la copia dell'URL %{url} negli appunti", + "downloadDialogTitle": "Scarica %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Scarica nel formato originale" }, "menu": { "library": "Libreria", + "librarySelector": { + "allLibraries": "Tutte le librerie (%{count})", + "multipleLibraries": "%{selected} di %{total} librerie", + "selectLibraries": "Seleziona librerie", + "none": "Nessuna" + }, "settings": "Impostazioni", "version": "Versione", "theme": "Tema", @@ -387,21 +621,22 @@ "language": "Lingua", "defaultView": "Vista Predefinita", "desktop_notifications": "Notifiche desktop", + "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", + "listenBrainzScrobbling": "Esegui lo scrobbling tramite ListenBrainz", + "replaygain": "Modalità ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", "gain": { - "none": "", - "album": "", - "track": "" + "none": "Disabilitato", + "album": "Usa guadagno album", + "track": "Usa guadagno traccia" } } }, "albumList": "Album", - "about": "Info", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise" + "sharedPlaylists": "Playlist Condivise", + "about": "Info" }, "player": { "playListsText": "Coda", @@ -432,29 +667,59 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste" + "featureRequests": "Richieste", + "lastInsightsCollection": "Ultima raccolta dati", + "insights": { + "disabled": "Disabilitato", + "waiting": "In attesa" + } + }, + "tabs": { + "about": "Info", + "config": "Configurazione" + }, + "config": { + "configName": "Nome configurazione", + "environmentVariable": "Variabile d'ambiente", + "currentValue": "Valore attuale", + "configurationFile": "File di configurazione", + "exportToml": "Esporta configurazione (TOML)", + "downloadToml": "Scarica configurazione (TOML)", + "exportSuccess": "Configurazione esportata negli appunti in formato TOML", + "exportFailed": "Impossibile copiare la configurazione", + "devFlagsHeader": "Flag di sviluppo (soggetti a modifiche/rimozione)", + "devFlagsComment": "Queste sono impostazioni sperimentali e potrebbero essere rimosse in versioni future" } }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate", - "quickScan": "Scansione veloce", - "fullScan": "Scansione completa", - "serverUptime": "Periodo di attività", - "serverDown": "OFFLINE" + "totalScanned": "Cartelle scansionate totali", + "quickScan": "Rapida", + "fullScan": "Completa", + "selectiveScan": "Selettiva", + "serverUptime": "Periodo di attività del server", + "serverDown": "OFFLINE", + "scanType": "Ultima scansione", + "status": "Errore di scansione", + "elapsedTime": "Tempo trascorso" + }, + "nowPlaying": { + "title": "In riproduzione", + "empty": "Nessuna riproduzione in corso", + "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera", + "title": "Scorciatoie da Tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", "prev_song": "Traccia Precedente", "next_song": "Traccia Successiva", + "current_song": "Vai alla traccia corrente", "vol_up": "Alza il Volume", "vol_down": "Abbassa il Volume", - "toggle_love": "Aggiungi questa traccia ai preferiti", - "current_song": "" + "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} +} \ No newline at end of file From 81a17f6bbb933fe03bca12ffe3c51244b3b452da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 25 Apr 2026 20:27:38 -0400 Subject: [PATCH 05/28] =?UTF-8?q?fix(search):=20normalization=20for=20non-?= =?UTF-8?q?NFKD=20Unicode=20letters=20(=C3=B8,=20=C3=A6,=20=C5=93,=20?= =?UTF-8?q?=C3=9F)=20(#5413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(search): transliterate non-ASCII letters symmetrically in FTS5 path Songs and artists with letters like ø, æ, œ, ß were unsearchable. The query path in server/subsonic/searching.go transliterates with sanitize.Accents (Øystein → Oystein), but the FTS5 tokenizer's remove_diacritics 2 only strips NFKD-decomposable marks — atomic letters with built-in strokes/ligatures survive tokenization, so the query side and index side disagreed. Apply sanitize.Accents on both sides: - normalizeForFTS now also emits an ASCII-transliterated form for each word, so search_normalized contains the variant the query produces. - buildFTS5Query transliterates the unquoted portion of the input so every caller (Subsonic, REST fullTextFilter) gets the same handling. Quoted phrases stay as typed, preserving phrase matches against the original title/artist columns. Existing libraries pick up the fix as records are re-scanned; users can trigger a manual full rescan to refresh older entries. * fix(search): cache transliteration and add ß/quoted-phrase test coverage Address review feedback: call sanitize.Accents once per word and reuse the result for both the punct-stripped and accent-only paths. Add missing test entries for ß→ss transliteration and quoted Unicode phrase preservation. --------- Co-authored-by: Claude --- persistence/sql_search_fts.go | 43 +++++++++++++++++++++--------- persistence/sql_search_fts_test.go | 18 +++++++++++-- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 1d4116b5d..e9b961d91 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" . "github.com/Masterminds/squirrel" + "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -44,24 +45,33 @@ var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) // fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) -// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word, -// and returns a space-separated string of words that changed after stripping (deduplicated). -// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC". +// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of +// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) +// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed +// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — +// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree +// without an explicit transliterated entry here. func normalizeForFTS(values ...string) string { seen := make(map[string]struct{}) var result []string + add := func(orig, variant string) { + if variant == "" || variant == orig { + return + } + lower := strings.ToLower(variant) + if _, ok := seen[lower]; ok { + return + } + seen[lower] = struct{}{} + result = append(result, variant) + } for _, v := range values { for _, word := range strings.Fields(v) { - stripped := fts5PunctStrip.ReplaceAllString(word, "") - if stripped == "" || stripped == word { - continue - } - lower := strings.ToLower(stripped) - if _, ok := seen[lower]; ok { - continue - } - seen[lower] = struct{}{} - result = append(result, stripped) + transliterated := sanitize.Accents(word) + // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. + add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) + // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). + add(word, transliterated) } } return strings.Join(result, " ") @@ -158,6 +168,13 @@ func buildFTS5Query(userInput string) string { result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:] } + // Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …) + // so the query matches the ASCII variants emitted by normalizeForFTS at index time. + // FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without + // this step queries for words containing these letters can miss. Quoted phrases are + // left untouched so they continue to match the original text in title/artist columns. + result = sanitize.Accents(result) + // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive: // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens) result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index d0e26c8e3..b54e5856a 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -37,7 +37,13 @@ var _ = DescribeTable("buildFTS5Query", Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), + Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"), + Entry("transliterates ø to o", "Øystein", "Oystein*"), + Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"), + Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"), + Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"), + Entry("transliterates ß to ss", "Straße", "Strasse*"), + Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), @@ -75,11 +81,19 @@ var _ = DescribeTable("normalizeForFTS", Entry("strips dots and concatenates", "REM", "R.E.M."), Entry("strips slash", "ACDC", "AC/DC"), Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged words", "", "The Beatles"), + Entry("skips unchanged ASCII words", "", "The Beatles"), Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), Entry("strips apostrophe from word", "N", "Guns N' Roses"), Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), + Entry("transliterates ø to o", "Bjork", "Bjørk"), + Entry("transliterates Ø to O", "Oystein", "Øystein"), + Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), + Entry("transliterates Latin diacritics", "cafe", "café"), + Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), + Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), + Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), + Entry("transliterates ß to ss", "Strasse", "Straße"), ) var _ = DescribeTable("containsCJK", From 5d1c9530abbe4bdd772e80a8ba3dff785e6789a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 25 Apr 2026 20:54:02 -0400 Subject: [PATCH 06/28] feat(cli): add pls export/import subcommands for bulk playlist management (#5412) * refactor: rename ImportFile to ImportFromFolder in playlists service * feat: add ImportFile method with library/folder resolution * feat: allow sync flag upgrade on re-import of non-synced playlists * feat: add pls export subcommand with bulk and single export Add `navidrome pls export` command that supports: - Single playlist export to stdout (-p flag only) - Single playlist export to directory (-p and -o flags) - Bulk export of all playlists to a directory (-o flag only) - Filtering by user (-u flag) - Automatic filename sanitization and collision detection Also extracts findPlaylist helper from runExporter for reuse. * feat: add pls import subcommand with sync flag support * fix: improve error message for export without output directory * test: add tests for ImportFile sync flag and sync upgrade behavior * refactor: streamline export and import logic by removing redundant comments and improving library path matching Signed-off-by: Deluan * feat: update ImportFile method to include sync flag for playlist imports Signed-off-by: Deluan * feat: implement fetchPlaylists function to streamline playlist retrieval Signed-off-by: Deluan * feat: replace inline filename sanitization with centralized utility function Signed-off-by: Deluan * feat: refactor playlist import logic to consolidate sync handling and improve method signatures Signed-off-by: Deluan * fix: address code review feedback on playlist import/export - Fix duplicate playlist creation on non-sync re-import: only reconcile sync flag when the playlist was actually persisted (has an ID) - Distinguish "not in any library" from real errors in resolveFolder using a sentinel error, so DB/folder errors propagate instead of falling back to ImportM3U - Use bufio.Scanner in countM3UTrackLines instead of reading entire file * feat: replace bufio.Scanner with UTF8Reader and LinesFrom utility for improved file reading Signed-off-by: Deluan * fix: record path for outside-library imports to prevent duplicates Files outside all libraries now go through updatePlaylist with the absolute path recorded, so re-importing the same file updates the existing playlist instead of creating a duplicate. * refactor: name guard condition in updatePlaylist for readability Extracted the compound boolean expression into a named local variable `alreadyImportedAndNotSynced` to make the intent of the early-return guard clearer at a glance. * add godocs Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/pls.go | 224 ++++++++++++++++++++++++++---- core/archiver.go | 11 +- core/playlists/import.go | 110 +++++++++++++-- core/playlists/import_test.go | 214 ++++++++++++++++++++++++---- core/playlists/parse_m3u.go | 13 +- core/playlists/playlists.go | 5 +- scanner/phase_4_playlists.go | 2 +- scanner/phase_4_playlists_test.go | 6 +- utils/str/sanitize_strings.go | 16 +++ 9 files changed, 516 insertions(+), 85 deletions(-) diff --git a/cmd/pls.go b/cmd/pls.go index 9b94c9e8f..95cbe4eec 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -7,11 +7,19 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" + "strings" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/spf13/cobra" ) @@ -20,6 +28,7 @@ var ( outputFile string userID string outputFormat string + syncFlag bool ) type displayPlaylist struct { @@ -41,6 +50,15 @@ func init() { listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]") plsCmd.AddCommand(listCommand) + + exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID") + exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory") + exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") + plsCmd.AddCommand(exportCommand) + + importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)") + importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced") + plsCmd.AddCommand(importCommand) } var ( @@ -60,72 +78,165 @@ var ( runList(cmd.Context()) }, } + + exportCommand = &cobra.Command{ + Use: "export", + Short: "Export playlists to M3U files", + Long: "Export one or more Navidrome playlists to M3U files", + Run: func(cmd *cobra.Command, args []string) { + runExport(cmd.Context()) + }, + } + + importCommand = &cobra.Command{ + Use: "import [files...]", + Short: "Import M3U playlists", + Long: "Import one or more M3U files as Navidrome playlists", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runImport(cmd.Context(), args) + }, + } ) -func runExporter(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) +func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists { + options := model.QueryOptions{Sort: sort} + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + options.Filters = squirrel.Eq{"owner_id": user.ID} + } + pls, err := ds.Playlist(ctx).GetAll(options) + if err != nil { + log.Fatal(ctx, "Failed to retrieve playlists", err) + } + return pls +} + +func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { + playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}}) + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if len(playlists) > 0 { playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } } } if playlist == nil { - log.Fatal("Playlist not found", "name", playlistID) + log.Fatal("Playlist not found", "name", nameOrID) } + return playlist +} + +func runExporter(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + playlist := findPlaylist(ctx, ds, playlistID) pls := playlist.ToM3U8() if outputFile == "-" || outputFile == "" { println(pls) return } - - err = os.WriteFile(outputFile, []byte(pls), 0600) + err := os.WriteFile(outputFile, []byte(pls), 0600) if err != nil { log.Fatal("Error writing to the output file", "file", outputFile, err) } } +func runExport(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + + if playlistID != "" && outputFile == "" { + playlist := findPlaylist(ctx, ds, playlistID) + println(playlist.ToM3U8()) + return + } + + if outputFile == "" { + log.Fatal("Output directory (-o) is required for bulk export or when filtering by user") + } + + info, err := os.Stat(outputFile) + if err != nil || !info.IsDir() { + log.Fatal("Output path must be an existing directory", "path", outputFile) + } + + if playlistID != "" { + pls := findPlaylist(ctx, ds, playlistID) + filename := str.SanitizeFilename(pls.Name) + ".m3u" + path := filepath.Join(outputFile, filename) + err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600) + if err != nil { + log.Fatal("Error writing playlist", "file", path, err) + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + return + } + + allPls := fetchPlaylists(ctx, ds, "name") + + nameCounts := make(map[string]int) + for _, pls := range allPls { + nameCounts[str.SanitizeFilename(pls.Name)]++ + } + + exported := 0 + for _, pls := range allPls { + plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + if err != nil { + log.Error("Error loading playlist tracks", "playlist", pls.Name, err) + continue + } + + sanitized := str.SanitizeFilename(pls.Name) + filename := sanitized + ".m3u" + if nameCounts[sanitized] > 1 { + shortID := pls.ID + if len(shortID) > 6 { + shortID = shortID[:6] + } + filename = sanitized + "_" + shortID + ".m3u" + } + + path := filepath.Join(outputFile, filename) + err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600) + if err != nil { + log.Error("Error writing playlist", "file", path, err) + continue + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + exported++ + } + fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile) +} + func runList(ctx context.Context) { if outputFormat != "csv" && outputFormat != "json" { log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) } ds, ctx := getAdminContext(ctx) - options := model.QueryOptions{Sort: "owner_name"} - - if userID != "" { - user, err := getUser(ctx, userID, ds) - if err != nil { - log.Fatal(ctx, "Error retrieving user", "username or id", userID) - } - options.Filters = squirrel.Eq{"owner_id": user.ID} - } - - playlists, err := ds.Playlist(ctx).GetAll(options) - if err != nil { - log.Fatal(ctx, "Failed to retrieve playlists", err) - } + allPls := fetchPlaylists(ctx, ds, "owner_name") if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) _ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"}) - for _, playlist := range playlists { + for _, playlist := range allPls { _ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)}) } w.Flush() } else { - display := make(displayPlaylists, len(playlists)) - for idx, playlist := range playlists { + display := make(displayPlaylists, len(allPls)) + for idx, playlist := range allPls { display[idx].Id = playlist.ID display[idx].Name = playlist.Name display[idx].OwnerId = playlist.OwnerID @@ -137,3 +248,62 @@ func runList(ctx context.Context) { fmt.Printf("%s\n", j) } } + +func runImport(ctx context.Context, files []string) { + ds, ctx := getAdminContext(ctx) + + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + ctx = request.WithUser(ctx, *user) + } + + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + + for _, file := range files { + absPath, err := filepath.Abs(file) + if err != nil { + log.Error("Error resolving path", "file", file, err) + fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err) + continue + } + + totalLines := countM3UTrackLines(absPath) + + imported, err := pls.ImportFile(ctx, absPath, syncFlag) + if err != nil { + log.Error("Error importing playlist", "file", absPath, err) + fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err) + continue + } + + matched := len(imported.Tracks) + if totalLines > 0 { + notFound := totalLines - matched + fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound) + } else { + fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched) + } + } +} + +func countM3UTrackLines(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + + count := 0 + reader := ioutils.UTF8Reader(file) + for line := range slice.LinesFrom(reader) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + count++ + } + return count +} diff --git a/core/archiver.go b/core/archiver.go index 8305c4f6c..96cc2c31e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Archiver interface { @@ -87,7 +88,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc if isMultiDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file) + return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { @@ -126,7 +127,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { - plsName := sanitizeName(name) + plsName := str.SanitizeFilename(name) w, err := z.CreateHeader(&zip.FileHeader{ Name: plsName + ".m3u", Modified: mfs[0].UpdatedAt, @@ -156,11 +157,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) if format != "" && format != "raw" { ext = format } - return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext) -} - -func sanitizeName(target string) string { - return strings.ReplaceAll(target, "/", "_") + return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { diff --git a/core/playlists/import.go b/core/playlists/import.go index 4462554c7..9d3ecabc5 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -3,6 +3,7 @@ package playlists import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -17,14 +18,89 @@ import ( "golang.org/x/text/unicode/norm" ) -func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) { + absPath, err := filepath.Abs(absolutePath) + if err != nil { + return nil, fmt.Errorf("resolving absolute path: %w", err) + } + + dir := filepath.Dir(absPath) + filename := filepath.Base(absPath) + + folder, err := s.resolveFolder(ctx, dir) + if err != nil && !errors.Is(err, errNotInLibrary) { + return nil, err + } + if err == nil { + pls, err := s.importFromFolder(ctx, folder, filename, sync) + if err != nil { + return nil, err + } + if pls.ID != "" && pls.Sync != sync { + pls.Sync = sync + if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil { + return nil, putErr + } + } + return pls, nil + } + + log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath) + pls, err := s.newSyncedPlaylist(dir, filename) + if err != nil { + return nil, fmt.Errorf("reading playlist file: %w", err) + } + pls.Sync = sync + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("opening playlist file: %w", err) + } + defer file.Close() + + reader := ioutils.UTF8Reader(file) + if err := s.parseM3U(ctx, pls, nil, reader); err != nil { + return nil, err + } + if err := s.updatePlaylist(ctx, pls, sync); err != nil { + return nil, err + } + return pls, nil +} + +var errNotInLibrary = fmt.Errorf("path not in any library") + +func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) { + libs, err := s.ds.Library(ctx).GetAll() + if err != nil { + return nil, err + } + matcher := newLibraryMatcher(libs) + lib, ok := matcher.findLibrary(dir) + if !ok { + return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) + } + + folder, err := s.ds.Folder(ctx).GetByPath(lib, dir) + if err != nil { + return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err) + } + folder.LibraryPath = lib.Path + return folder, nil +} + +func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + return s.importFromFolder(ctx, folder, filename, false) +} + +func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) { pls, err := s.parsePlaylist(ctx, filename, folder) if err != nil { log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) return nil, err } log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) - err = s.updatePlaylist(ctx, pls) + err = s.updatePlaylist(ctx, pls, forceSync) if err != nil { log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) } @@ -74,27 +150,31 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold return pls, err } -func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { - owner, _ := request.UserFrom(ctx) - - // Try to find existing playlist by path. Since filesystem normalization differs across - // platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match - // playlists that may have been imported on a different platform. - pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) +// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode +// normalization forms to handle cross-platform filesystem differences. +func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) { + pls, err := s.ds.Playlist(ctx).FindByPath(path) if errors.Is(err, model.ErrNotFound) { - // Try alternate normalization form - altPath := norm.NFD.String(newPls.Path) - if altPath == newPls.Path { - altPath = norm.NFC.String(newPls.Path) + altPath := norm.NFD.String(path) + if altPath == path { + altPath = norm.NFC.String(path) } - if altPath != newPls.Path { + if altPath != path { pls, err = s.ds.Playlist(ctx).FindByPath(altPath) } } + return pls, err +} + +func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error { + owner, _ := request.UserFrom(ctx) + + pls, err := s.findByPathNormalized(ctx, newPls.Path) if err != nil && !errors.Is(err, model.ErrNotFound) { return err } - if err == nil && !pls.Sync { + alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync + if alreadyImportedAndNotSynced { log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) return nil } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 53855d781..f2866fb60 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() { ctx = request.WithUser(ctx, model.User{ID: "123"}) }) - Describe("ImportFile", func() { + Describe("ImportFromFolder", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -59,7 +59,7 @@ var _ = Describe("Playlists - Import", func() { Describe("M3U", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "pls1.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Tracks).To(HaveLen(2)) @@ -69,19 +69,19 @@ var _ = Describe("Playlists - Import", func() { }) It("parses playlists using LF ending", func() { - pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists using CR ending (old Mac format)", func() { - pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists with UTF-8 BOM marker", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("Test Playlist")) @@ -90,7 +90,7 @@ var _ = Describe("Playlists - Import", func() { }) It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("UTF-16 Test Playlist")) @@ -101,7 +101,7 @@ var _ = Describe("Playlists - Import", func() { It("parses #EXTALBUMARTURL with HTTP URL", func() { conf.Server.EnableM3UExternalAlbumArt = true - pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) Expect(pls.Tracks).To(HaveLen(2)) @@ -121,7 +121,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -139,7 +139,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg"))) }) @@ -158,7 +158,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -177,7 +177,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -195,7 +195,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -212,7 +212,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -229,7 +229,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -247,7 +247,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -275,12 +275,38 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.UploadedImage).To(Equal("existing-id.jpg")) Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg")) }) + It("skips non-synced playlist on re-import (respects user's choice)", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: plsFile, + Sync: false, + OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + // updatePlaylist skips the non-synced playlist, so the returned + // playlist has no ID (was never persisted/updated). + Expect(pls.ID).To(BeEmpty()) + }) + It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) @@ -301,7 +327,7 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -309,7 +335,7 @@ var _ = Describe("Playlists - Import", func() { Describe("NSP", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(Equal(pls)) Expect(pls.OwnerID).To(Equal("123")) @@ -322,17 +348,17 @@ var _ = Describe("Playlists - Import", func() { }) It("returns an error if the playlist is not well-formed", func() { tests.SkipOnWindows("line-ending differences affect JSON error offset") - _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp") + _, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp") Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'")) }) It("parses NSP with public: true and creates public playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Public Playlist")) Expect(pls.Public).To(BeTrue()) }) It("parses NSP with public: false and creates private playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Private Playlist")) Expect(pls.Public).To(BeFalse()) @@ -340,7 +366,7 @@ var _ = Describe("Playlists - Import", func() { It("uses server default when public field is absent", func() { conf.Server.DefaultPlaylistPublicVisibility = true - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true @@ -386,7 +412,7 @@ var _ = Describe("Playlists - Import", func() { Path: "", Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u") Expect(err).ToNot(HaveOccurred()) // Should update existing playlist, not create new one @@ -441,7 +467,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -462,7 +488,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should only find abc.mp3, not outside.mp3 Expect(pls.Tracks).To(HaveLen(1)) @@ -499,7 +525,7 @@ var _ = Describe("Playlists - Import", func() { Name: "subfolder", // The folder name } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -542,7 +568,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library @@ -593,7 +619,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should have BOTH tracks, not just one @@ -616,6 +642,126 @@ var _ = Describe("Playlists - Import", func() { }) }) + Describe("ImportFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} + }) + + It("resolves file inside a library and imports it", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", + LibraryID: 1, + LibraryPath: tmpDir, + Path: "", + Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" + plsFile := filepath.Join(tmpDir, "my-playlist.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("My Playlist")) + Expect(pls.Tracks).To(HaveLen(2)) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeTrue()) + }) + + It("records path for files outside all libraries", func() { + tmpDir := GinkgoT().TempDir() + libDir := filepath.Join(tmpDir, "music") + Expect(os.Mkdir(libDir, 0755)).To(Succeed()) + mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" + plsFile := filepath.Join(tmpDir, "external.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("External Playlist")) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=false", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=true", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeTrue()) + }) + + It("upgrades non-synced playlist to synced on re-import with sync=true", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", Name: "Existing", Path: plsFile, + Sync: false, OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).To(Equal("existing-id")) + Expect(pls.Sync).To(BeTrue()) + }) + }) + Describe("ImportM3U", func() { var repo *mockedMediaFileFromListRepo BeforeEach(func() { @@ -925,3 +1071,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi } return mfs, nil } + +type mockFolderRepoForImport struct { + model.FolderRepository + folder *model.Folder +} + +func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) { + if m.folder != nil { + return m.folder, nil + } + return nil, model.ErrNotFound +} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index b9f5c92a2..a64c337c9 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -163,17 +163,26 @@ type libraryMatcher struct { // findLibraryForPath finds which library contains the given absolute path. // Returns library ID and path, or 0 and empty string if not found. func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { + lib, ok := lm.findLibrary(absolutePath) + if !ok { + return 0, "" + } + return lib.ID, filepath.Clean(lib.Path) +} + +// findLibrary checks if the absolute path is under any of the library paths. +func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { // Check sorted libraries (longest path first) to find the best match for i, cleanLibPath := range lm.cleanedPaths { // Check if absolutePath is under this library path if strings.HasPrefix(absolutePath, cleanLibPath) { // Ensure it's a proper path boundary (not just a prefix) if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i].ID, cleanLibPath + return lm.libraries[i], true } } } - return 0, "" + return model.Library{}, false } // newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index a0086cd2d..3da24706c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -42,10 +42,11 @@ type Playlists interface { RemoveImage(ctx context.Context, playlistID string) error // Import - ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) + ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) + ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) - // REST adapters (follows Share/Library pattern) + // REST adapters NewRepository(ctx context.Context) rest.Repository TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index ab5f77ae0..f726343f2 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -100,7 +100,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. continue } // BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc) - pls, err := p.pls.ImportFile(p.ctx, folder, f.Name()) + pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name()) if err != nil { continue } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 06e6fa686..0e01a7549 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -97,9 +97,9 @@ var _ = Describe("phasePlaylists", func() { _ = os.WriteFile(file1, []byte{}, 0600) _ = os.WriteFile(file2, []byte{}, 0600) - pls.On("ImportFile", mock.Anything, folder, "playlist1.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). Return(&model.Playlist{}, nil) - pls.On("ImportFile", mock.Anything, folder, "playlist2.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u"). Return(&model.Playlist{}, nil) _, err := phase.processPlaylistsInFolder(folder) @@ -134,7 +134,7 @@ type mockPlaylists struct { playlists.Playlists } -func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { args := p.Called(ctx, folder, filename) return args.Get(0).(*model.Playlist), args.Error(1) } diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index c121aefe7..11f828270 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -52,6 +52,22 @@ func SanitizeHTML(text string) string { return policy.Sanitize(html.UnescapeString(text)) } +var filenameReplacer = strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", +) + +func SanitizeFilename(name string) string { + return filenameReplacer.Replace(name) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) From 0ab10e819f85b7146726b82906525215130d0d38 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 26 Apr 2026 10:43:54 -0400 Subject: [PATCH 07/28] refactor: simplify criteria Expression interface Replaced the Fields() type switch with a fields() method on the Expression interface, eliminating the need to update a central switch when adding new expression types. Removed the now-redundant criteriaExpression() marker method since fields() alone suffices to restrict the interface. Extracted a conjunction interface for the ChildPlaylistIds() lookup used by All and Any. --- model/criteria/criteria.go | 4 +- model/criteria/operators.go | 81 ++++++++++++++++++++----------------- model/criteria/walk.go | 37 +---------------- model/criteria/walk_test.go | 2 +- 4 files changed, 47 insertions(+), 77 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 1e161c9f2..e204e6972 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -9,7 +9,7 @@ import ( ) type Expression interface { - criteriaExpression() + fields() map[string]any } type Criteria struct { @@ -53,7 +53,7 @@ func (c Criteria) ChildPlaylistIds() []string { return nil } - if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok { + if parent, ok := c.Expression.(conjunction); ok { return parent.ChildPlaylistIds() } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 6f911b12f..983c6aa1a 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -2,12 +2,17 @@ package criteria import "time" +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +type conjunction interface { + ChildPlaylistIds() []string +} + type ( All []Expression And = All ) -func (All) criteriaExpression() {} +func (All) fields() map[string]any { return nil } func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -22,7 +27,7 @@ type ( Or = Any ) -func (Any) criteriaExpression() {} +func (Any) fields() map[string]any { return nil } func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -35,128 +40,128 @@ func (any Any) ChildPlaylistIds() (ids []string) { type Is map[string]any type Eq = Is -func (Is) criteriaExpression() {} - func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } +func (is Is) fields() map[string]any { return is } + type IsNot map[string]any -func (IsNot) criteriaExpression() {} - -func (in IsNot) MarshalJSON() ([]byte, error) { - return marshalExpression("isNot", in) +func (isn IsNot) MarshalJSON() ([]byte, error) { + return marshalExpression("isNot", isn) } -type Gt map[string]any +func (isn IsNot) fields() map[string]any { return isn } -func (Gt) criteriaExpression() {} +type Gt map[string]any func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt map[string]any +func (gt Gt) fields() map[string]any { return gt } -func (Lt) criteriaExpression() {} +type Lt map[string]any func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before map[string]any +func (lt Lt) fields() map[string]any { return lt } -func (Before) criteriaExpression() {} +type Before map[string]any func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) } -type After Gt +func (bf Before) fields() map[string]any { return bf } -func (After) criteriaExpression() {} +type After Gt func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) } -type Contains map[string]any +func (af After) fields() map[string]any { return af } -func (Contains) criteriaExpression() {} +type Contains map[string]any func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) } -type NotContains map[string]any +func (ct Contains) fields() map[string]any { return ct } -func (NotContains) criteriaExpression() {} +type NotContains map[string]any func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) } -type StartsWith map[string]any +func (nct NotContains) fields() map[string]any { return nct } -func (StartsWith) criteriaExpression() {} +type StartsWith map[string]any func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) } +func (sw StartsWith) fields() map[string]any { return sw } + type EndsWith map[string]any -func (EndsWith) criteriaExpression() {} - -func (sw EndsWith) MarshalJSON() ([]byte, error) { - return marshalExpression("endsWith", sw) +func (ew EndsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("endsWith", ew) } -type InTheRange map[string]any +func (ew EndsWith) fields() map[string]any { return ew } -func (InTheRange) criteriaExpression() {} +type InTheRange map[string]any func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) } -type InTheLast map[string]any +func (itr InTheRange) fields() map[string]any { return itr } -func (InTheLast) criteriaExpression() {} +type InTheLast map[string]any func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) } -type NotInTheLast map[string]any +func (itl InTheLast) fields() map[string]any { return itl } -func (NotInTheLast) criteriaExpression() {} +type NotInTheLast map[string]any func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } +func (nitl NotInTheLast) fields() map[string]any { return nitl } + func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } type InPlaylist map[string]any -func (InPlaylist) criteriaExpression() {} - func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) } +func (ipl InPlaylist) fields() map[string]any { return ipl } + type NotInPlaylist map[string]any -func (NotInPlaylist) criteriaExpression() {} - -func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { - return marshalExpression("notInPlaylist", ipl) +func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { + return marshalExpression("notInPlaylist", nipl) } +func (nipl NotInPlaylist) fields() map[string]any { return nipl } + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/walk.go b/model/criteria/walk.go index 62aaf97f8..acaf48289 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -32,41 +32,6 @@ func Walk(expr Expression, visit Visitor) error { return nil } -// Fields returns field values for leaf expressions only. -// Use Walk to traverse All and Any expressions before calling Fields. func Fields(expr Expression) map[string]any { - switch e := expr.(type) { - case Is: - return map[string]any(e) - case IsNot: - return map[string]any(e) - case Gt: - return map[string]any(e) - case Lt: - return map[string]any(e) - case Before: - return map[string]any(e) - case After: - return map[string]any(Gt(e)) - case Contains: - return map[string]any(e) - case NotContains: - return map[string]any(e) - case StartsWith: - return map[string]any(e) - case EndsWith: - return map[string]any(e) - case InTheRange: - return map[string]any(e) - case InTheLast: - return map[string]any(e) - case NotInTheLast: - return map[string]any(e) - case InPlaylist: - return map[string]any(e) - case NotInPlaylist: - return map[string]any(e) - default: - return nil - } + return expr.fields() } diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go index 91438f095..2e0f12f8d 100644 --- a/model/criteria/walk_test.go +++ b/model/criteria/walk_test.go @@ -9,7 +9,7 @@ import ( type unknownExpression struct{} -func (unknownExpression) criteriaExpression() {} +func (unknownExpression) fields() map[string]any { return nil } var _ = Describe("Walk", func() { It("visits the expression tree depth-first", func() { From 1bd736dae99db4cee344d26c797ddc2d7c86f7ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 26 Apr 2026 14:49:59 -0400 Subject: [PATCH 08/28] refactor: centralize criteria sort parsing and extract smart playlist logic (#5415) * test: add tests for recordingdate alias resolution in smart playlists Signed-off-by: Deluan * refactor: update FieldInfo structure and simplify fieldMap initialization Signed-off-by: Deluan * refactor: move sort parsing logic from persistence to criteria package Extracted sort field parsing, validation, and direction handling from persistence/criteria_sql.go into model/criteria/sort.go. The new OrderByFields method on Criteria parses the Sort/Order strings into validated SortField structs (field name + direction), resolving aliases and handling +/- prefixes and order inversion. The persistence layer now consumes these parsed fields and only handles SQL expression mapping. This centralizes sort parsing to enforce consistent implementations. * refactor: standardize field access in smartPlaylistCriteria structure Signed-off-by: Deluan * refactor: add ResolveLimit method to Criteria Moved the percentage-limit resolution logic from playlist_repository into Criteria.ResolveLimit, replacing the 3-line mutate-after-query pattern with a single method call. The method preserves LimitPercent rather than zeroing it, since IsPercentageLimit already returns false once Limit is set, making the clear redundant and lossy. * refactor: improve child playlist loading and error handling in refresh logic Signed-off-by: Deluan * refactor: extract smart playlist logic to dedicated files Moved refreshSmartPlaylist, addSmartPlaylistAnnotationJoins, and addCriteria methods from playlist_repository.go to a new smart_playlist_repository.go file. Extracted all smart playlist tests to smart_playlist_repository_test.go. Added DeferCleanup to the "valid rules" test to fix ordering flakiness when Ginkgo randomizes test execution across files. * refactor: break refreshSmartPlaylist into smaller focused methods Split the monolithic refreshSmartPlaylist method into discrete helpers for readability: shouldRefreshSmartPlaylist for guard checks, refreshChildPlaylists for recursive dependency refresh, resolvePercentageLimit for count-based limit resolution, buildSmartPlaylistQuery for assembling the SELECT with joins, and addMediaFileAnnotationJoin to DRY up the repeated annotation join clause. * refactor: deduplicate child playlist IDs in Criteria Signed-off-by: Deluan * refactor: simplify withSmartPlaylistOwner to accept model.User Replaced separate ownerID string and ownerIsAdmin bool parameters with a single model.User struct, reducing the field count in smartPlaylistCriteria and making the option function signature clearer. Updated all call sites and tests accordingly. * fix: handle empty sort fields and propagate child playlist load errors OrderByFields now falls back to [{title, asc}] when all user-supplied sort fields are invalid, preventing empty ORDER BY clauses that would produce invalid SQL in row_number() window functions. Also restored the original behavior where a DB error loading child playlists aborts the parent smart playlist refresh, by making refreshChildPlaylists return a bool. * refactor: log warning when no valid sort fields are found Signed-off-by: Deluan --------- Signed-off-by: Deluan --- model/criteria/criteria.go | 20 +- model/criteria/criteria_test.go | 47 ++ model/criteria/fields.go | 160 +++--- model/criteria/sort.go | 62 ++ model/criteria/sort_test.go | 103 ++++ persistence/criteria_sql.go | 90 +-- persistence/criteria_sql_test.go | 8 +- persistence/e2e/smartplaylist_test.go | 5 + persistence/playlist_repository.go | 156 ----- persistence/playlist_repository_test.go | 511 ----------------- persistence/smart_playlist_repository.go | 203 +++++++ persistence/smart_playlist_repository_test.go | 531 ++++++++++++++++++ 12 files changed, 1069 insertions(+), 827 deletions(-) create mode 100644 model/criteria/sort.go create mode 100644 model/criteria/sort_test.go create mode 100644 persistence/smart_playlist_repository.go create mode 100644 persistence/smart_playlist_repository_test.go diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index e204e6972..31d208d08 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,6 +4,7 @@ package criteria import ( "encoding/json" "errors" + "slices" "github.com/navidrome/navidrome/log" ) @@ -42,6 +43,16 @@ func (c Criteria) EffectiveLimit(totalCount int64) int { return 0 } +// ResolveLimit converts a percentage-based limit into an absolute Limit using +// the given totalCount. It is a no-op when a fixed Limit is already set or when +// no percentage limit is configured. +func (c *Criteria) ResolveLimit(totalCount int64) { + if !c.IsPercentageLimit() { + return + } + c.Limit = c.EffectiveLimit(totalCount) +} + // IsPercentageLimit returns true when the criteria uses a valid percentage-based // limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it). func (c Criteria) IsPercentageLimit() bool { @@ -53,11 +64,14 @@ func (c Criteria) ChildPlaylistIds() []string { return nil } - if parent, ok := c.Expression.(conjunction); ok { - return parent.ChildPlaylistIds() + parent, ok := c.Expression.(conjunction) + if !ok { + return nil } - return nil + ids := parent.ChildPlaylistIds() + slices.Sort(ids) + return slices.Compact(ids) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index e0940a509..092cfd36a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -177,6 +177,39 @@ var _ = Describe("Criteria", func() { }) }) + Describe("ResolveLimit", func() { + It("resolves percentage to absolute limit preserving LimitPercent", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + + It("does nothing when Limit is already set", func() { + c := Criteria{Limit: 50, LimitPercent: 10} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(50)) + }) + + It("does nothing when no limit is configured", func() { + c := Criteria{} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(0)) + }) + + It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() { + c := Criteria{LimitPercent: 1} + c.ResolveLimit(5) + gomega.Expect(c.Limit).To(gomega.Equal(1)) + }) + + It("is idempotent when called twice", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + }) + Describe("IsPercentageLimit", func() { It("returns true when LimitPercent is set and Limit is 0", func() { c := Criteria{LimitPercent: 10} @@ -269,5 +302,19 @@ var _ = Describe("Criteria", func() { ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("deduplicates repeated playlist IDs", func() { + sharedID := uuid.NewString() + goObj = Criteria{ + Expression: All{ + InPlaylist{"id": sharedID}, + Any{ + InPlaylist{"id": sharedID}, + NotInPlaylist{"id": sharedID}, + }, + }, + } + ids := goObj.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.Equal([]string{sharedID})) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 30541a945..20c0048b3 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,90 +2,83 @@ package criteria import "strings" -// FieldInfo describes a criteria field without tying it to persistence details. +// FieldInfo contains semantic metadata about a criteria field type FieldInfo struct { Name string IsTag bool IsRole bool Numeric bool + alias string } -var fieldMap = map[string]*fieldMetadata{ - "title": {name: "title"}, - "album": {name: "album"}, - "hascoverart": {name: "hascoverart"}, - "tracknumber": {name: "tracknumber"}, - "discnumber": {name: "discnumber"}, - "year": {name: "year"}, - "date": {name: "date", alias: "recordingdate"}, - "originalyear": {name: "originalyear"}, - "originaldate": {name: "originaldate"}, - "releaseyear": {name: "releaseyear"}, - "releasedate": {name: "releasedate"}, - "size": {name: "size"}, - "compilation": {name: "compilation"}, - "missing": {name: "missing"}, - "explicitstatus": {name: "explicitstatus"}, - "dateadded": {name: "dateadded"}, - "datemodified": {name: "datemodified"}, - "discsubtitle": {name: "discsubtitle"}, - "comment": {name: "comment"}, - "lyrics": {name: "lyrics"}, - "sorttitle": {name: "sorttitle"}, - "sortalbum": {name: "sortalbum"}, - "sortartist": {name: "sortartist"}, - "sortalbumartist": {name: "sortalbumartist"}, - "albumcomment": {name: "albumcomment"}, - "catalognumber": {name: "catalognumber"}, - "filepath": {name: "filepath"}, - "filetype": {name: "filetype"}, - "codec": {name: "codec"}, - "duration": {name: "duration"}, - "bitrate": {name: "bitrate"}, - "bitdepth": {name: "bitdepth"}, - "samplerate": {name: "samplerate"}, - "bpm": {name: "bpm"}, - "channels": {name: "channels"}, - "loved": {name: "loved"}, - "dateloved": {name: "dateloved"}, - "lastplayed": {name: "lastplayed"}, - "daterated": {name: "daterated"}, - "playcount": {name: "playcount"}, - "rating": {name: "rating"}, - "averagerating": {name: "averagerating", numeric: true}, - "albumrating": {name: "albumrating"}, - "albumloved": {name: "albumloved"}, - "albumplaycount": {name: "albumplaycount"}, - "albumlastplayed": {name: "albumlastplayed"}, - "albumdateloved": {name: "albumdateloved"}, - "albumdaterated": {name: "albumdaterated"}, - "artistrating": {name: "artistrating"}, - "artistloved": {name: "artistloved"}, - "artistplaycount": {name: "artistplaycount"}, - "artistlastplayed": {name: "artistlastplayed"}, - "artistdateloved": {name: "artistdateloved"}, - "artistdaterated": {name: "artistdaterated"}, - "mbz_album_id": {name: "mbz_album_id"}, - "mbz_album_artist_id": {name: "mbz_album_artist_id"}, - "mbz_artist_id": {name: "mbz_artist_id"}, - "mbz_recording_id": {name: "mbz_recording_id"}, - "mbz_release_track_id": {name: "mbz_release_track_id"}, - "mbz_release_group_id": {name: "mbz_release_group_id"}, - "library_id": {name: "library_id", numeric: true}, +var fieldMap = map[string]FieldInfo{ + "title": {Name: "title"}, + "album": {Name: "album"}, + "hascoverart": {Name: "hascoverart"}, + "tracknumber": {Name: "tracknumber"}, + "discnumber": {Name: "discnumber"}, + "year": {Name: "year"}, + "date": {Name: "date", alias: "recordingdate"}, + "originalyear": {Name: "originalyear"}, + "originaldate": {Name: "originaldate"}, + "releaseyear": {Name: "releaseyear"}, + "releasedate": {Name: "releasedate"}, + "size": {Name: "size"}, + "compilation": {Name: "compilation"}, + "missing": {Name: "missing"}, + "explicitstatus": {Name: "explicitstatus"}, + "dateadded": {Name: "dateadded"}, + "datemodified": {Name: "datemodified"}, + "discsubtitle": {Name: "discsubtitle"}, + "comment": {Name: "comment"}, + "lyrics": {Name: "lyrics"}, + "sorttitle": {Name: "sorttitle"}, + "sortalbum": {Name: "sortalbum"}, + "sortartist": {Name: "sortartist"}, + "sortalbumartist": {Name: "sortalbumartist"}, + "albumcomment": {Name: "albumcomment"}, + "catalognumber": {Name: "catalognumber"}, + "filepath": {Name: "filepath"}, + "filetype": {Name: "filetype"}, + "codec": {Name: "codec"}, + "duration": {Name: "duration"}, + "bitrate": {Name: "bitrate"}, + "bitdepth": {Name: "bitdepth"}, + "samplerate": {Name: "samplerate"}, + "bpm": {Name: "bpm"}, + "channels": {Name: "channels"}, + "loved": {Name: "loved"}, + "dateloved": {Name: "dateloved"}, + "lastplayed": {Name: "lastplayed"}, + "daterated": {Name: "daterated"}, + "playcount": {Name: "playcount"}, + "rating": {Name: "rating"}, + "averagerating": {Name: "averagerating", Numeric: true}, + "albumrating": {Name: "albumrating"}, + "albumloved": {Name: "albumloved"}, + "albumplaycount": {Name: "albumplaycount"}, + "albumlastplayed": {Name: "albumlastplayed"}, + "albumdateloved": {Name: "albumdateloved"}, + "albumdaterated": {Name: "albumdaterated"}, + "artistrating": {Name: "artistrating"}, + "artistloved": {Name: "artistloved"}, + "artistplaycount": {Name: "artistplaycount"}, + "artistlastplayed": {Name: "artistlastplayed"}, + "artistdateloved": {Name: "artistdateloved"}, + "artistdaterated": {Name: "artistdaterated"}, + "mbz_album_id": {Name: "mbz_album_id"}, + "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, + "mbz_artist_id": {Name: "mbz_artist_id"}, + "mbz_recording_id": {Name: "mbz_recording_id"}, + "mbz_release_track_id": {Name: "mbz_release_track_id"}, + "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {name: "releasetype", isTag: true}, + "albumtype": {Name: "releasetype", IsTag: true}, - "random": {name: "random"}, - "value": {name: "value"}, -} - -type fieldMetadata struct { - name string - isRole bool - isTag bool - alias string - numeric bool + "random": {Name: "random"}, + "value": {Name: "value"}, } // AllFieldNames returns the names of all registered criteria fields. @@ -100,15 +93,7 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { f, ok := fieldMap[strings.ToLower(name)] - if !ok { - return FieldInfo{}, false - } - return FieldInfo{ - Name: f.name, - IsTag: f.isTag, - IsRole: f.isRole, - Numeric: f.numeric, - }, true + return f, ok } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in @@ -119,7 +104,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &fieldMetadata{name: name, isRole: true} + fieldMap[name] = FieldInfo{Name: name, IsRole: true} } } @@ -138,7 +123,7 @@ func AddTagNames(tagNames []string) { } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &fieldMetadata{name: name, isTag: true} + fieldMap[name] = FieldInfo{Name: name, IsTag: true} } } } @@ -148,9 +133,10 @@ func AddNumericTags(tagNames []string) { for _, tagName := range tagNames { name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { - fm.numeric = true + fm.Numeric = true + fieldMap[name] = fm } else { - fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} + fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} } } } diff --git a/model/criteria/sort.go b/model/criteria/sort.go new file mode 100644 index 000000000..05b108cf9 --- /dev/null +++ b/model/criteria/sort.go @@ -0,0 +1,62 @@ +package criteria + +import ( + "strings" + + "github.com/navidrome/navidrome/log" +) + +type SortField struct { + Field string + Desc bool +} + +func (c Criteria) OrderByFields() []SortField { + sortValue := c.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]SortField, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + desc := false + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + desc = strings.HasPrefix(part, "-") + part = strings.TrimSpace(part[1:]) + } + info, ok := LookupField(part) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", part) + continue + } + if order == "desc" { + desc = !desc + } + fields = append(fields, SortField{Field: info.Name, Desc: desc}) + } + if len(fields) == 0 { + log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) + return []SortField{{Field: "title", Desc: false}} + } + return fields +} + +func (c Criteria) SortFieldNames() []string { + sortFields := c.OrderByFields() + names := make([]string, len(sortFields)) + for i, sf := range sortFields { + names[i] = sf.Field + } + return names +} diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go new file mode 100644 index 000000000..35db88549 --- /dev/null +++ b/model/criteria/sort_test.go @@ -0,0 +1,103 @@ +package criteria + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = Describe("OrderByFields", func() { + It("defaults to title ascending when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses a single field", func() { + c := Criteria{Sort: "title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses descending prefix", func() { + c := Criteria{Sort: "-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}})) + }) + + It("parses ascending prefix", func() { + c := Criteria{Sort: "+title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses multiple comma-separated fields", func() { + c := Criteria{Sort: "title,-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("inverts directions when Order is desc", func() { + c := Criteria{Sort: "-date,title", Order: "desc"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "date", Desc: false}, + {Field: "title", Desc: true}, + })) + }) + + It("skips invalid fields", func() { + c := Criteria{Sort: "bogus,title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("falls back to title when all fields are invalid", func() { + c := Criteria{Sort: "bogus,invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("resolves tag aliases (albumtype -> releasetype)", func() { + c := Criteria{Sort: "albumtype"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}})) + }) + + It("resolves field aliases (recordingdate -> date)", func() { + AddTagNames([]string{"recordingdate"}) + c := Criteria{Sort: "recordingdate"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}})) + }) + + It("handles the random field", func() { + c := Criteria{Sort: "random"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}})) + }) + + It("ignores invalid Order value", func() { + c := Criteria{Sort: "-title", Order: "invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}})) + }) + + It("handles whitespace in fields", func() { + c := Criteria{Sort: " title , -rating "} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("skips empty parts from trailing commas", func() { + c := Criteria{Sort: "title,,rating,"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: false}, + })) + }) +}) + +var _ = Describe("SortFieldNames", func() { + It("returns canonical field names", func() { + c := Criteria{Sort: "title,-rating,albumtype"} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"})) + }) + + It("defaults to title when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"})) + }) +}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index c75f6467b..1431f0d0e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -9,7 +9,7 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" ) @@ -32,23 +32,21 @@ type smartPlaylistField struct { } type smartPlaylistCriteria struct { - criteria criteria.Criteria - ownerID string - ownerIsAdmin bool + criteria.Criteria + owner model.User } func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { - cSQL := smartPlaylistCriteria{criteria: c} + cSQL := smartPlaylistCriteria{Criteria: c} for _, opt := range opts { opt(&cSQL) } return cSQL } -func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { +func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) { return func(c *smartPlaylistCriteria) { - c.ownerID = ownerID - c.ownerIsAdmin = ownerIsAdmin + c.owner = owner } } @@ -119,10 +117,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { - if c.criteria.Expression == nil { + if c.Criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } - return c.exprSQL(c.criteria.Expression) + return c.exprSQL(c.Criteria.Expression) } func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { @@ -290,13 +288,13 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return nil, errors.New("playlist id not given") } filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} - if !c.ownerIsAdmin { - if c.ownerID == "" { + if !c.owner.IsAdmin { + if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) } else { filters = append(filters, squirrel.Or{ squirrel.Eq{"playlist.public": 1}, - squirrel.Eq{"playlist.owner_id": c.ownerID}, + squirrel.Eq{"playlist.owner_id": c.owner.ID}, }) } } @@ -404,7 +402,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { var joins smartPlaylistJoinType - _ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { + _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { for field := range criteria.Fields(expr) { joins |= fieldJoinType(field) } @@ -415,69 +413,27 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { joins := c.ExpressionJoins() - for _, sortField := range sortFields(c.criteria.Sort) { - joins |= fieldJoinType(sortField) + for _, name := range c.Criteria.SortFieldNames() { + joins |= fieldJoinType(name) } return joins } func (c smartPlaylistCriteria) OrderBy() string { - sortValue := c.criteria.Sort - if sortValue == "" { - sortValue = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.criteria.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order) - order = "" - } - - parts := strings.Split(sortValue, ",") - fields := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { + sortFields := c.Criteria.OrderByFields() + parts := make([]string, 0, len(sortFields)) + for _, sf := range sortFields { + mapped, ok := sortExpr(sf.Field) + if !ok { continue } dir := "asc" - if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { - if strings.HasPrefix(part, "-") { - dir = "desc" - } - part = strings.TrimSpace(part[1:]) + if sf.Desc { + dir = "desc" } - sortField := strings.ToLower(part) - mapped, ok := sortExpr(sortField) - if !ok { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - fields = append(fields, mapped+" "+dir) + parts = append(parts, mapped+" "+dir) } - return strings.Join(fields, ", ") -} - -func sortFields(sortValue string) []string { - if sortValue == "" { - sortValue = "title" - } - parts := strings.Split(sortValue, ",") - fields := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-")) - if part != "" { - fields = append(fields, part) - } - } - return fields + return strings.Join(parts, ", ") } func sortExpr(sortField string) (string, bool) { diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e06a4eccb..e02032d9a 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -3,6 +3,7 @@ package persistence import ( "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -11,7 +12,7 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) criteria.AddNumericTags([]string{"rate"}) }) @@ -54,6 +55,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), + Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), @@ -63,7 +65,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("allows public or same-owner playlist references for regular users", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, - withSmartPlaylistOwner("owner-id", false), + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), ).Where() Expect(err).ToNot(HaveOccurred()) @@ -76,7 +78,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("allows all playlist references for admins", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, - withSmartPlaylistOwner("admin-id", true), + withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), ).Where() Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 658030de5..086e73703 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -197,6 +197,11 @@ var _ = Describe("Smart Playlists", func() { Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) }) + + It("resolves recordingdate alias to the date column", func() { + results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) }) Describe("Logic operators", func() { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 11e76fa8b..9bbc41c5c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -11,7 +11,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" @@ -201,161 +200,6 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec Columns(r.tableName+".*", "user.user_name as owner_name") } -func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { - // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config - if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) { - return false - } - - // Never refresh other users' playlists - usr := loggedUser(r.ctx) - if pls.OwnerID != usr.ID { - log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) - return false - } - - log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) - start := time.Now() - - // Remove old tracks - del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) - _, err := r.executeSQL(del) - if err != nil { - log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Re-populate playlist based on Smart Playlist criteria - rules := *pls.Rules - rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin)) - - // If the playlist depends on other playlists, recursively refresh them first - childPlaylistIds := rules.ChildPlaylistIds() - for _, id := range childPlaylistIds { - childPls, err := r.Get(id) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) - continue - } - log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) - return false - } - r.refreshSmartPlaylist(childPls) - } - - orderBy := rulesSQL.OrderBy() - sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). - From("media_file").LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - - // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) - - // Only include media files from libraries the user has access to - sq = r.applyLibraryFilter(sq, "media_file") - - // Resolve percentage-based limit to an absolute number before applying criteria - if rules.IsPercentageLimit() { - // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rulesSQL.ExpressionJoins() - countSq := Select("count(*) as count").From("media_file"). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) - countSq = r.applyLibraryFilter(countSq, "media_file") - cond, err := rulesSQL.Where() - if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) - return false - } - countSq = countSq.Where(cond) - - var res struct{ Count int64 } - err = r.queryOne(countSq, &res) - if err != nil { - log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) - return false - } - resolvedLimit := rules.EffectiveLimit(res.Count) - log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) - rules.Limit = resolvedLimit - rules.LimitPercent = 0 - rulesSQL.criteria = rules - } - - // Apply the criteria rules - sq, err = r.addCriteria(sq, rulesSQL) - if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) - return false - } - insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) - _, err = r.executeSQL(insSql) - if err != nil { - log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update playlist stats - err = r.refreshCounters(pls) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update when the playlist was last refreshed (for cache purposes) - now := time.Now() - updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(updSql) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - pls.EvaluatedAt = &now - - log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) - - return true -} - -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - return sq -} - -func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { - cond, err := cSQL.Where() - if err != nil { - return sql, err - } - sql = sql.Where(cond) - if cSQL.criteria.Limit > 0 { - sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset)) - } - if order := cSQL.OrderBy(); order != "" { - sql = sql.OrderBy(order) - } - return sql, nil -} - func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 88cb5f697..cfabd0983 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,17 +1,11 @@ package persistence import ( - "time" - - "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/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -128,379 +122,6 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Context("Smart Playlists", func() { - var rules *criteria.Criteria - BeforeEach(func() { - rules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "love"}, - }, - } - }) - Context("valid rules", func() { - Specify("Put/Get", func() { - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - - savedPls, err := repo.Get(newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedPls.Rules).To(Equal(rules)) - }) - }) - - Context("invalid rules", func() { - It("fails to Put it in the DB", func() { - rules = &criteria.Criteria{ - // This is invalid because "contains" cannot have multiple fields - Expression: criteria.All{ - criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, - }, - } - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) - }) - }) - - Context("child smart playlists", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - When("refresh delay has expired", func() { - It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) - - // Getting parent with refresh should recursively refresh the nested playlist - pls, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.EvaluatedAt).ToNot(BeNil()) - Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - - // Nested playlist should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - }) - }) - - When("refresh delay has not expired", func() { - It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - childEvaluatedAt := time.Now().Add(-30 * time.Minute) - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - // Parent has no EvaluatedAt, so it WILL refresh, but the child should not - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - - // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - // Parent should have been refreshed (its EvaluatedAt was nil) - Expect(parent.EvaluatedAt).ToNot(BeNil()) - Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Nested playlist should NOT have been refreshed (still within delay window) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) - }) - }) - }) - }) - - Describe("Playlist Track Sorting", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - Expect(repo.Delete(testPlaylistID)).To(BeNil()) - testPlaylistID = "" - } - }) - - It("sorts tracks correctly by album (disc and track number)", func() { - By("creating a playlist with multi-disc album tracks in arbitrary order") - newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} - // Add tracks in intentionally scrambled order - newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(newPls.ID, false) - tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) - Expect(err).ToNot(HaveOccurred()) - - By("verifying tracks are sorted by disc number then track number") - Expect(tracks).To(HaveLen(4)) - // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 - Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 - Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 - Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 - Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 - }) - }) - - Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - }) - - It("matches tracks from starred albums using albumLoved", func() { - // albumRadioactivity (ID "103") is starred in test fixtures - // Songs in album 103: 1003, 1004, 1005, 1006 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) - }) - - It("matches tracks from starred artists using artistLoved", func() { - // artistBeatles (ID "3") is starred in test fixtures - // Songs with ArtistID "3": 1001, 1002, 3002 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) - }) - - It("matches tracks with combined album and artist criteria", func() { - // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) - // artistLoved=true → songs with artist 3 (1001, 1002) - // Using Any: union of both sets - rules := &criteria.Criteria{ - Expression: criteria.Any{ - criteria.Is{"albumLoved": true}, - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) - }) - - It("returns no tracks when no albums/artists match", func() { - // No album has rating 5 in fixtures - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumRating": 5}, - }, - } - newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - Expect(pls.Tracks).To(BeEmpty()) - }) - }) - - Describe("Smart Playlists with Tag Criteria", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var songWithGrouping, songWithoutGrouping model.MediaFile - - BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) - - // Register 'grouping' as a valid tag for smart playlists - criteria.AddTagNames([]string{"grouping"}) - - // Create a song with the grouping tag - songWithGrouping = model.MediaFile{ - ID: "test-grouping-1", - Title: "Song With Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "test/grouping/song1.mp3", - Tags: model.Tags{ - "grouping": []string{"My Crate"}, - }, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) - - // Create a song without the grouping tag - songWithoutGrouping = model.MediaFile{ - ID: "test-grouping-2", - Title: "Song Without Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "test/grouping/song2.mp3", - Tags: model.Tags{}, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) - }) - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test media files - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() - }) - - It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { - By("creating a smart playlist that checks if grouping tag has any value") - // This is the workaround for issue #4728: using 'contains' with empty string - // generates SQL: value LIKE '%%' which matches any non-empty string - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track with grouping tag is matched") - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) - }) - - It("excludes tracks with a tag value using 'notContains' with empty string", func() { - By("creating a smart playlist that checks if grouping tag is NOT set") - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.NotContains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying the track with grouping is NOT in the playlist") - for _, track := range pls.Tracks { - Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) - } - - By("verifying the track without grouping IS in the playlist") - var foundWithoutGrouping bool - for _, track := range pls.Tracks { - if track.MediaFileID == songWithoutGrouping.ID { - foundWithoutGrouping = true - break - } - } - Expect(foundWithoutGrouping).To(BeTrue()) - }) - }) - Describe("Track Deletion and Renumbering", func() { var testPlaylistID string @@ -573,136 +194,4 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) - - Describe("Smart Playlists Library Filtering", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var lib2ID int - var restrictedUserID string - var uniqueLibPath string - - BeforeEach(func() { - db := GetDBXBuilder() - - // Generate unique IDs for this test run - uniqueSuffix := time.Now().Format("20060102150405.000") - restrictedUserID = "restricted-user-" + uniqueSuffix - uniqueLibPath = "/music/lib2-" + uniqueSuffix - - // Create a second library with unique name and path to avoid conflicts with other tests - _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) - Expect(err).ToNot(HaveOccurred()) - err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) - Expect(err).ToNot(HaveOccurred()) - - // Create a restricted user with access only to library 1 - _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - - // Create test media files in each library - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, db) - - // Song in library 1 (accessible by restricted user) - songLib1 := model.MediaFile{ - ID: "lib1-song", - Title: "Song in Lib1", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "lib1/song.mp3", - LibraryID: 1, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib1)).To(Succeed()) - - // Song in library 2 (NOT accessible by restricted user) - songLib2 := model.MediaFile{ - ID: "lib2-song", - Title: "Song in Lib2", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "lib2/song.mp3", - LibraryID: lib2ID, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib2)).To(Succeed()) - }) - - AfterEach(func() { - db := GetDBXBuilder() - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test data - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() - _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() - _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() - _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) - }) - - It("should only include tracks from libraries the user has access to (issue #4738)", func() { - db := GetDBXBuilder() - ctx := log.NewContext(GinkgoT().Context()) - - // Create the smart playlist as the restricted user - restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} - ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo := NewPlaylistRepository(ctx, db) - - // Create a smart playlist that matches all songs - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Gt{"playCount": -1}, // Matches everything - }, - } - newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} - Expect(restrictedRepo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track from library 1 is in the playlist") - var foundLib1Song, foundLib2Song bool - for _, track := range pls.Tracks { - if track.MediaFileID == "lib1-song" { - foundLib1Song = true - } - if track.MediaFileID == "lib2-song" { - foundLib2Song = true - } - } - Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") - Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") - - By("verifying playlist_tracks table only contains the accessible track") - var playlistTracksCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) - Expect(err).ToNot(HaveOccurred()) - // Count should only include tracks visible to the user (lib1-song) - // The count may include other test songs from library 1, but NOT lib2-song - var lib2TrackCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) - Expect(err).ToNot(HaveOccurred()) - Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") - - By("verifying SongCount matches visible tracks") - Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") - }) - }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go new file mode 100644 index 000000000..54f316152 --- /dev/null +++ b/persistence/smart_playlist_repository.go @@ -0,0 +1,203 @@ +package persistence + +import ( + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated +// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist +// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists +// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and +// configured refresh delay. + +// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + usr := loggedUser(r.ctx) + if !r.shouldRefreshSmartPlaylist(pls, usr) { + return false + } + + log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + start := time.Now() + + del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) + if _, err := r.executeSQL(del); err != nil { + log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + + if !r.refreshChildPlaylists(pls, rulesSQL) { + return false + } + + if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil { + return false + } + + sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID) + sq, err := r.addCriteria(sq, rulesSQL) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) + if _, err = r.executeSQL(insSql); err != nil { + log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + if err = r.refreshCounters(pls); err != nil { + log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + now := time.Now() + updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) + if _, err = r.executeSQL(updSql); err != nil { + log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) + return false + } + pls.EvaluatedAt = &now + + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + return true +} + +// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated +// time, and ownership. +func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool { + if !pls.IsSmartPlaylist() { + return false + } + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + return false + } + if pls.OwnerID != usr.ID { + log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + return false + } + return true +} + +// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. +// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { + childPlaylistIds := rulesSQL.ChildPlaylistIds() + if len(childPlaylistIds) == 0 { + return true + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + if err != nil { + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + return false + } + + found := make(map[string]struct{}, len(childPlaylists)) + for i := range childPlaylists { + found[childPlaylists[i].ID] = struct{}{} + r.refreshSmartPlaylist(&childPlaylists[i]) + } + for _, id := range childPlaylistIds { + if _, ok := found[id]; !ok { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + } + } + return true +} + +// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit. +func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { + if !rulesSQL.IsPercentageLimit() { + return nil + } + + exprJoins := rulesSQL.ExpressionJoins() + countSq := Select("count(*) as count").From("media_file") + countSq = r.addMediaFileAnnotationJoin(countSq, userID) + countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.applyLibraryFilter(countSq, "media_file") + + cond, err := rulesSQL.Where() + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return err + } + countSq = countSq.Where(cond) + + var res struct{ Count int64 } + if err = r.queryOne(countSq, &res); err != nil { + log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + return err + } + + rulesSQL.ResolveLimit(res.Count) + log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) + return nil +} + +// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, +// including necessary joins for annotations and library filtering. +func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { + orderBy := rulesSQL.OrderBy() + sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + From("media_file") + sq = r.addMediaFileAnnotationJoin(sq, userID) + + requiredJoins := rulesSQL.RequiredJoins() + sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID) + sq = r.applyLibraryFilter(sq, "media_file") + return sq +} + +// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include +// user-specific annotations in the smart playlist criteria evaluation. +func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder { + return sq.LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", userID) +} + +// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on +// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } + return sq +} + +// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the +// smart playlist criteria. +func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { + cond, err := cSQL.Where() + if err != nil { + return sql, err + } + sql = sql.Where(cond) + if cSQL.Criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) + } + if order := cSQL.OrderBy(); order != "" { + sql = sql.OrderBy(order) + } + return sql, nil +} diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go new file mode 100644 index 000000000..207fe0c36 --- /dev/null +++ b/persistence/smart_playlist_repository_test.go @@ -0,0 +1,531 @@ +package persistence + +import ( + "time" + + "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/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("PlaylistRepository - Smart Playlists", func() { + var repo model.PlaylistRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + }) + + Context("Smart Playlists", func() { + var rules *criteria.Criteria + BeforeEach(func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "love"}, + }, + } + }) + Context("valid rules", func() { + Specify("Put/Get", func() { + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + savedPls, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedPls.Rules).To(Equal(rules)) + }) + }) + + Context("invalid rules", func() { + It("fails to Put it in the DB", func() { + rules = &criteria.Criteria{ + // This is invalid because "contains" cannot have multiple fields + Expression: criteria.All{ + criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, + }, + } + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) + }) + }) + + Context("child smart playlists", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + When("refresh delay has expired", func() { + It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + // Nested playlist has not been evaluated yet + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) + + // Getting parent with refresh should recursively refresh the nested playlist + pls, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.EvaluatedAt).ToNot(BeNil()) + Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Parent should have tracks from the nested playlist + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + + // Nested playlist should now have been refreshed (EvaluatedAt set) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + }) + }) + + When("refresh delay has not expired", func() { + It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + childEvaluatedAt := time.Now().Add(-30 * time.Minute) + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + // Parent has no EvaluatedAt, so it WILL refresh, but the child should not + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + // Getting parent with refresh should NOT recursively refresh the nested playlist + parent, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + // Parent should have been refreshed (its EvaluatedAt was nil) + Expect(parent.EvaluatedAt).ToNot(BeNil()) + Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Nested playlist should NOT have been refreshed (still within delay window) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) + }) + }) + }) + }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) + + Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + }) + + It("matches tracks from starred albums using albumLoved", func() { + // albumRadioactivity (ID "103") is starred in test fixtures + // Songs in album 103: 1003, 1004, 1005, 1006 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) + }) + + It("matches tracks from starred artists using artistLoved", func() { + // artistBeatles (ID "3") is starred in test fixtures + // Songs with ArtistID "3": 1001, 1002, 3002 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) + }) + + It("matches tracks with combined album and artist criteria", func() { + // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) + // artistLoved=true → songs with artist 3 (1001, 1002) + // Using Any: union of both sets + rules := &criteria.Criteria{ + Expression: criteria.Any{ + criteria.Is{"albumLoved": true}, + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) + }) + + It("returns no tracks when no albums/artists match", func() { + // No album has rating 5 in fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumRating": 5}, + }, + } + newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + Expect(pls.Tracks).To(BeEmpty()) + }) + }) + + Describe("Smart Playlists with Tag Criteria", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var songWithGrouping, songWithoutGrouping model.MediaFile + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + + // Register 'grouping' as a valid tag for smart playlists + criteria.AddTagNames([]string{"grouping"}) + + // Create a song with the grouping tag + songWithGrouping = model.MediaFile{ + ID: "test-grouping-1", + Title: "Song With Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song1.mp3", + Tags: model.Tags{ + "grouping": []string{"My Crate"}, + }, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + + // Create a song without the grouping tag + songWithoutGrouping = model.MediaFile{ + ID: "test-grouping-2", + Title: "Song Without Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song2.mp3", + Tags: model.Tags{}, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + }) + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test media files + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() + }) + + It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { + By("creating a smart playlist that checks if grouping tag has any value") + // This is the workaround for issue #4728: using 'contains' with empty string + // generates SQL: value LIKE '%%' which matches any non-empty string + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track with grouping tag is matched") + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) + }) + + It("excludes tracks with a tag value using 'notContains' with empty string", func() { + By("creating a smart playlist that checks if grouping tag is NOT set") + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.NotContains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the track with grouping is NOT in the playlist") + for _, track := range pls.Tracks { + Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) + } + + By("verifying the track without grouping IS in the playlist") + var foundWithoutGrouping bool + for _, track := range pls.Tracks { + if track.MediaFileID == songWithoutGrouping.ID { + foundWithoutGrouping = true + break + } + } + Expect(foundWithoutGrouping).To(BeTrue()) + }) + }) + + Describe("Smart Playlists Library Filtering", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var lib2ID int + var restrictedUserID string + var uniqueLibPath string + + BeforeEach(func() { + db := GetDBXBuilder() + + // Generate unique IDs for this test run + uniqueSuffix := time.Now().Format("20060102150405.000") + restrictedUserID = "restricted-user-" + uniqueSuffix + uniqueLibPath = "/music/lib2-" + uniqueSuffix + + // Create a second library with unique name and path to avoid conflicts with other tests + _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) + Expect(err).ToNot(HaveOccurred()) + err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) + Expect(err).ToNot(HaveOccurred()) + + // Create a restricted user with access only to library 1 + _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + + // Create test media files in each library + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, db) + + // Song in library 1 (accessible by restricted user) + songLib1 := model.MediaFile{ + ID: "lib1-song", + Title: "Song in Lib1", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib1/song.mp3", + LibraryID: 1, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib1)).To(Succeed()) + + // Song in library 2 (NOT accessible by restricted user) + songLib2 := model.MediaFile{ + ID: "lib2-song", + Title: "Song in Lib2", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib2/song.mp3", + LibraryID: lib2ID, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib2)).To(Succeed()) + }) + + AfterEach(func() { + db := GetDBXBuilder() + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test data + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() + _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() + _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() + _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) + }) + + It("should only include tracks from libraries the user has access to (issue #4738)", func() { + db := GetDBXBuilder() + ctx := log.NewContext(GinkgoT().Context()) + + // Create the smart playlist as the restricted user + restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} + ctx = request.WithUser(ctx, restrictedUser) + restrictedRepo := NewPlaylistRepository(ctx, db) + + // Create a smart playlist that matches all songs + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Gt{"playCount": -1}, // Matches everything + }, + } + newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} + Expect(restrictedRepo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track from library 1 is in the playlist") + var foundLib1Song, foundLib2Song bool + for _, track := range pls.Tracks { + if track.MediaFileID == "lib1-song" { + foundLib1Song = true + } + if track.MediaFileID == "lib2-song" { + foundLib2Song = true + } + } + Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") + Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") + + By("verifying playlist_tracks table only contains the accessible track") + var playlistTracksCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) + Expect(err).ToNot(HaveOccurred()) + // Count should only include tracks visible to the user (lib1-song) + // The count may include other test songs from library 1, but NOT lib2-song + var lib2TrackCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") + + By("verifying SongCount matches visible tracks") + Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") + }) + }) +}) From fd930eefd73df9a62c1c1b21370a2389c783fcee Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 26 Apr 2026 16:36:57 -0400 Subject: [PATCH 09/28] feat(plugins): add LibraryID to TrackInfo Add LibraryID field to TrackInfo so plugins with library filesystem access can determine which library a track belongs to. This lets plugins resolve the full filesystem path by combining the library's root path with the track's relative path. LibraryID is gated behind the same filesystem access permission check as Path. --- plugins/capabilities/scrobbler.go | 3 +++ plugins/scrobbler_adapter.go | 1 + plugins/scrobbler_adapter_test.go | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 34cf60015..ed8a4fb6c 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 4f7cd4661..02c2b2889 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -130,6 +130,7 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo MBZReleaseTrackID: mf.MbzReleaseTrackID, } if p.hasLibraryFilesystemAccess(mf.LibraryID) { + ti.LibraryID = int32(mf.LibraryID) ti.Path = mf.Path } return ti diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 0ee229022..c56d8a900 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -259,19 +259,25 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }, } - It("includes Path when the plugin has filesystem access to the track's library", func() { + It("includes LibraryID and Path when the plugin has filesystem access to the track's library", func() { p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac")) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(Equal(int32(1))) + Expect(ti.Path).To(Equal("/music/test.flac")) }) - It("omits Path when the plugin lacks filesystem permission", func() { + It("omits LibraryID and Path when the plugin lacks filesystem permission", func() { p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) }) - It("omits Path when the track's library is not in the allowed set", func() { + It("omits LibraryID and Path when the track's library is not in the allowed set", func() { p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) }) }) }) From a756cad1dc0a94ee7456b9a765672cbd4ddaa3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 26 Apr 2026 17:34:39 -0400 Subject: [PATCH 10/28] test: enable artwork tests on Windows (#5416) * fix(test): enable artwork tests on Windows by using OS-aware path assertions Replace hardcoded forward-slash path expectations with filepath.FromSlash() so assertions match OS-native separators on Windows. Removes all 8 SkipOnWindows("#TBD-path-sep-artwork") guards from artwork unit tests. * test: add comment explaining forward-slash paths in test fixtures --- core/artwork/artwork_internal_test.go | 19 +++++++------------ core/artwork/reader_artist_test.go | 7 ++----- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 12a7085e8..7a48fa620 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -41,6 +41,7 @@ var _ = Describe("Artwork", func() { MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, } + // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} @@ -80,12 +81,11 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/test.mp3"))) }) It("returns ErrUnavailable if embed path is not available", func() { ffmpeg.Error = errors.New("not available") @@ -104,7 +104,6 @@ var _ = Describe("Artwork", func() { }) }) It("returns external cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"front.png"}, @@ -113,7 +112,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/front.png"))) }) It("returns ErrUnavailable if external file is not available", func() { folderRepo.result = []model.Folder{} @@ -135,13 +134,12 @@ var _ = Describe("Artwork", func() { }) DescribeTable("CoverArtPriority", func(priority string, expected string) { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.CoverArtPriority = priority aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(path).To(Equal(filepath.FromSlash(expected))) }, Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), @@ -213,13 +211,12 @@ var _ = Describe("Artwork", func() { }) DescribeTable("ArtistArtPriority", func(priority string, expected string) { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.ArtistArtPriority = priority aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(path).To(Equal(filepath.FromSlash(expected))) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -251,22 +248,20 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.mp3"))) }) It("returns embed cover if successfully extracted by ffmpeg", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) Expect(err).ToNot(HaveOccurred()) r, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) data, _ := io.ReadAll(r) Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal("tests/fixtures/test.ogg")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.ogg"))) }) It("returns album cover if cannot read embed artwork", func() { // Force fromTag to fail diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 220c7554f..33dc6ed57 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -62,13 +61,12 @@ var _ = Describe("artistArtworkReader", func() { When("artist has only one album", func() { It("returns the parent folder", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) @@ -88,14 +86,13 @@ var _ = Describe("artistArtworkReader", func() { When("the album paths contain same prefix", func() { It("returns the common prefix", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), filepath.FromSlash("/music/artist/album2"), } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) From 5d1c1157b5bde16c2b0ff6017bfe4a20bdbb6e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 26 Apr 2026 18:16:14 -0400 Subject: [PATCH 11/28] refactor(artwork): migrate readers to storage.MusicFS and add e2e suite (#5379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(artwork): add e2e suite documenting album/disc resolution Adds core/artwork/e2e/ with a real-tempdir + scanner harness that exercises artwork resolution end-to-end. Covers album and disc kinds; pending (PIt) cases document two known bugs in reader_album.go for regression-guard flipping once they are fixed. * refactor(artwork): add libraryFS helper to resolve MusicFS for a library * test(artwork): tighten libraryFS test isolation and add scheme-error case * test(artwork): update libraryFS test description to match implementation * refactor(artwork): convert fromExternalFile to use fs.FS Add a temporary fromExternalFileAbs shim so existing absolute-path callers still compile; the shim is removed once all readers are migrated. * refactor(artwork): make fromExternalFileAbs a thin delegator Introduce a minimal osDirectFS adapter so the shim no longer duplicates the matching loop. Both will be removed in Task 9. * refactor(artwork): convert fromTag to taglib.OpenStream over fs.FS Add a temporary fromTagAbs shim so existing absolute-path callers still compile; removed in Task 9. Reuses the osDirectFS adapter from Task 2. * refactor(artwork): defer fs.File close until after taglib reads finish Mirror the lifetime pattern used by adapters/gotaglib/gotaglib.go: keep the underlying fs.File open until taglib.File is closed, and pass WithFilename so format detection doesn't rely on content sniffing. * docs(artwork): note ffmpeg's path-based API limitation * refactor(artwork): migrate album reader to MusicFS - Add libFS (storage.MusicFS) field to albumArtworkReader; resolved once at construction time via libraryFS() - Switch fromCoverArtPriority from abs-path shims to FS-based fromTag/fromExternalFile; only fromFFmpegTag retains absolute path - Build imgFiles as library-relative forward-slash paths in loadAlbumFoldersPaths using path.Join(f.Path, f.Name, img) - Guard embedAbs so that an empty EmbedArtPath never produces a non-empty absolute path (prevents accidental ffmpeg invocation) - Register testfile:// storage scheme in artwork test suite to provide an os.DirFS-backed MusicFS without requiring the taglib extractor - Update test assertions from filepath.FromSlash(abs) to bare forward-slash relative strings * fix(artwork): use path package in compareImageFiles for forward-slash relative paths * refactor(artwork): migrate disc reader to MusicFS Replace os.Open absolute-path access with libFS.Open on library-relative forward-slash paths. Rename discFolders→discFoldersRel, split firstTrackPath into firstTrackRelPath (for fromTag) and firstTrackAbsPath (for fromFFmpegTag), and switch path.Dir/Base/Ext for forward-slash safety. * refactor(artwork): build discFoldersRel directly and guard empty first track * refactor(artwork): migrate mediafile reader to MusicFS * refactor(artwork): migrate artist album-art lookup to MusicFS * refactor(artwork): remove temporary path-based shims All readers now use the FS-based fromTag and fromExternalFile directly, so the absolute-path adapters and the osDirectFS helper that backed them can go away. * test(artwork): rewrite e2e suite to use storagetest.FakeFS Switches from real-tempdir + local storage to FakeFS via the storage registry. Adds a proper multi-disc scenario using the disc tag, which previously required curated MP3 fixtures we did not have. * test(artwork): use maps.Copy in trackFile tag merge Lint cleanup: replace the manual map-copy loop flagged by mapsloop. * test(artwork): reuse tests.MockFFmpeg in e2e harness Replace the hand-rolled noopFFmpeg stub with tests.NewMockFFmpeg, which already satisfies the full ffmpeg.FFmpeg interface and won't drift when new methods are added. Also tie imageBytes to imageFile so they cannot silently disagree on the on-disk encoding. * test(artwork): add e2e scenarios from artwork documentation Covers the behaviors documented at https://www.navidrome.org/docs/usage/library/artwork/: - Album: folder.*/front.* fallbacks and priority order with cover.*. - Disc: cd*.* match, cover.* inside disc folder, DiscArtPriority="" skip path, the documented multi-disc layout, and the discsubtitle keyword. - MediaFile: disc-level fallback for multi-disc tracks and album-level fallback for single-disc tracks (doc section "MediaFiles" items 2-3). - Artist: album/artist.* lookup via libFS (passes). The artist-folder branch is XIt-marked because fromArtistFolder still calls os.DirFS directly on an absolute path and can't read from a FakeFS-backed library — migrating that to storage.MusicFS is a follow-up. Signed-off-by: Deluan * refactor(artwork): scope artist folder traversal to library root Route fromArtistFolder reads through storage.MusicFS and bound the parent-directory walk at the library root. This keeps artwork resolution scoped to the configured library and unblocks FakeFS-backed e2e scenarios that depend on the artist folder. Also consolidate the libraryFS + core.AbsolutePath pairing (used by three readers) into a single libraryFSAndRoot helper. * test(artwork): add ASCII file-tree diagrams to e2e scenarios Each It/PIt block now shows the on-disk layout it exercises, with arrows indicating which file wins (or should win, for the known-bug PIt cases). Makes scenarios readable at a glance without having to parse the MapFS map. * test(artwork): add e2e tests for playlist and radio artwork resolution Signed-off-by: Deluan * test(artwork): enhance e2e tests with real MP3 fixtures for embedded artwork Signed-off-by: Deluan * test(ffmpeg): add support for animated WebP encoder detection and fallback handling Signed-off-by: Deluan * test(artwork): cover additional edge cases in e2e suite Add high-value scenarios uncovered by the existing specs: - Album: three-way basename tie (unsuffixed wins), unknown pattern in CoverArtPriority is skipped, embedded-first with no embedded art falls through. - Disc: discsubtitle with no matching image falls through. - Artist: ArtistArtPriority can reach images via album/. - Playlist: generates a 2x2 tiled cover from album art when the playlist has no uploaded/sidecar/external image. New helper realPNG() produces real taglib/image-decodable bytes so the tiled-cover test can exercise the generator's decode + compose path. * test(artwork): refactor image upload logic in e2e tests for consistency Signed-off-by: Deluan * test(ffmpeg): simplify animated WebP encoder check by removing context parameter Signed-off-by: Deluan * fix(artwork): normalize rel path for fs.Glob on Windows filepath.Rel returns backslash-separated paths on Windows, but fs.Glob and path.Join require forward slashes. Convert with filepath.ToSlash after computing the relative path and use path.Dir for the parent walk so the artist-folder lookup works cross-platform. * fix(ffmpeg): retry animated WebP probe on transient failure The probe previously used the caller's request context inside sync.Once, so a single cancelled first request would permanently disable animated WebP for the rest of the process. Switch to a mutex + probed flag, use a fresh background context with its own timeout, and only cache the result when the probe actually succeeds. * test(ffmpeg): reset ffOnce so ConvertAnimatedImage test is order-independent The ConvertAnimatedImage stand-in test sets ffmpegPath directly but does not reset ffOnce. If ffmpegCmd() has not been called earlier in the test process, the next call inside hasAnimatedWebPEncoder runs ffOnce.Do and re-resolves the real ffmpeg binary, overwriting the stand-in and breaking the test. Reset ffOnce and conf.Server.FFmpegPath alongside the other globals to pin resolution to the stand-in. * test(artwork): unblock Windows CI — forward-slash fs paths and suite-level DB lifetime The internal artwork test planted a Windows absolute path (backslashes) into Folder.Path and then fed it through libFS.Open, which fs.ValidPath rejects. Rooting the testfile library at the temp dir directly and using filepath.ToSlash keeps the path model library-relative and forward-slash, matching production. The e2e suite opened a per-spec DB in a per-spec TempDir, but the go-sqlite3 singleton kept the file open across specs. Ginkgo's per-spec TempDir cleanup then tried to unlink a file still held by that handle — fine on POSIX, fails on Windows. Moving the DB to a suite-level tempdir and closing it in AfterSuite avoids the race. * test(artwork): keep Windows drive letters intact in testfile library URLs url.Parse on `testfile://C:/path` reads `C` as the host and the path loses the drive letter, so Windows libFS lookups go to `/path` and fail. testFileLibPath now prepends a `/` when the OS path has no leading slash, and the testfile constructor strips that extra slash back off before handing the path to os.Stat / os.DirFS. * refactor(artwork): consolidate libFS + root into libraryView helper Collapses the per-reader libFS/libPath/rootFolder/firstTrackAbsPath fields into a single libraryView{FS, absRoot} with an Abs(rel) method. Also folds the two library lookups (ds.Library.Get + core.AbsolutePath) into one, and uses mf.Path directly instead of stripping libRoot off an absolute path. * refactor(ffmpeg): replace hasAnimatedWebPEncoder with encoderProbe for state management Signed-off-by: Deluan * fix: escape artist folder names in artwork glob Escape glob metacharacters in the library-relative artist folder path before composing the fs.Glob pattern for artist image lookup. This preserves literal folder names such as Artist [Live] while keeping the configured filename pattern behavior unchanged, and adds a regression test for bracketed artist folders. Signed-off-by: Deluan * fix(artwork): correct test path assertions after MusicFS migration Source functions (fromTag, fromExternalFile) now return forward-slash fs.FS-relative paths, so test assertions should compare against plain forward-slash strings, not filepath.FromSlash(). The artistArtPriority test needs filepath.FromSlash() on the suffix because findImageInFolder returns OS-native absolute paths via filepath.Join. * fix(artwork): normalize path separators in artistArtPriority assertion The two table entries exercise different code paths: entry 1 goes through fromArtistFolder (returns OS-native paths via filepath.Join), while entry 2 goes through fromExternalFile (returns forward-slash fs.FS paths). Using filepath.FromSlash on the expected value only works for entry 1. Normalize the actual path to forward slashes with filepath.ToSlash so a single HaveSuffix assertion works for both code paths on all platforms. --------- Signed-off-by: Deluan --- core/artwork/artwork_internal_test.go | 33 +- core/artwork/artwork_suite_test.go | 54 ++++ core/artwork/e2e/album_test.go | 354 +++++++++++++++++++++ core/artwork/e2e/artist_test.go | 167 ++++++++++ core/artwork/e2e/disc_test.go | 276 ++++++++++++++++ core/artwork/e2e/helpers_test.go | 184 +++++++++++ core/artwork/e2e/mediafile_test.go | 110 +++++++ core/artwork/e2e/playlist_test.go | 158 +++++++++ core/artwork/e2e/radio_test.go | 42 +++ core/artwork/e2e/suite_test.go | 106 ++++++ core/artwork/e2e/testdata/embedded_art.mp3 | Bin 0 -> 64223 bytes core/artwork/library_fs.go | 44 +++ core/artwork/library_fs_test.go | 45 +++ core/artwork/reader_album.go | 50 +-- core/artwork/reader_album_test.go | 35 +- core/artwork/reader_artist.go | 90 ++++-- core/artwork/reader_artist_test.go | 49 ++- core/artwork/reader_disc.go | 55 ++-- core/artwork/reader_disc_test.go | 137 ++++---- core/artwork/reader_mediafile.go | 11 +- core/artwork/sources.go | 48 ++- core/artwork/sources_internal_test.go | 92 ++++++ core/ffmpeg/ffmpeg.go | 66 ++++ core/ffmpeg/ffmpeg_test.go | 55 ++++ 24 files changed, 2084 insertions(+), 177 deletions(-) create mode 100644 core/artwork/e2e/album_test.go create mode 100644 core/artwork/e2e/artist_test.go create mode 100644 core/artwork/e2e/disc_test.go create mode 100644 core/artwork/e2e/helpers_test.go create mode 100644 core/artwork/e2e/mediafile_test.go create mode 100644 core/artwork/e2e/playlist_test.go create mode 100644 core/artwork/e2e/radio_test.go create mode 100644 core/artwork/e2e/suite_test.go create mode 100644 core/artwork/e2e/testdata/embedded_art.mp3 create mode 100644 core/artwork/library_fs.go create mode 100644 core/artwork/library_fs_test.go create mode 100644 core/artwork/sources_internal_test.go diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 7a48fa620..c95371959 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -37,9 +37,13 @@ var _ = Describe("Artwork", func() { conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" folderRepo = &fakeFolderRepo{} + libRepo := &tests.MockLibraryRepo{} + repoRoot, _ := os.Getwd() + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ds = &tests.MockDataStore{ MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, + MockedLibrary: libRepo, } // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} @@ -85,7 +89,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/test.mp3"))) + Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) }) It("returns ErrUnavailable if embed path is not available", func() { ffmpeg.Error = errors.New("not available") @@ -112,7 +116,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/front.png"))) + Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) }) It("returns ErrUnavailable if external file is not available", func() { folderRepo.result = []model.Folder{} @@ -139,7 +143,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash(expected))) + Expect(path).To(Equal(expected)) }, Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), @@ -195,9 +199,12 @@ var _ = Describe("Artwork", func() { Describe("artistArtworkReader", func() { Context("Multiple covers", func() { BeforeEach(func() { + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, }} ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ arMultipleCovers, @@ -216,7 +223,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash(expected))) + Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -252,7 +259,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.mp3"))) + Expect(path).To(Equal("tests/fixtures/test.mp3")) }) It("returns embed cover if successfully extracted by ffmpeg", func() { aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) @@ -261,7 +268,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) data, _ := io.ReadAll(r) Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.ogg"))) + Expect(path).To(Equal("tests/fixtures/test.ogg")) }) It("returns album cover if cannot read embed artwork", func() { // Force fromTag to fail @@ -460,7 +467,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ alCover, }) @@ -548,7 +558,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) conf.Server.CoverArtPriority = "cover.png" diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index dfd66e5e5..d42d7f3e4 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,9 +1,17 @@ package artwork import ( + "io/fs" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" "testing" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Artwork Suite") } + +// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. +// ReadTags is not used by albumArtworkReader, so it is left as a stub. +type osDirFS struct{ fs.FS } + +func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } + +// testFileScheme is the URL scheme registered to expose a tempdir as a +// storage.MusicFS for artwork integration tests. +const testFileScheme = "testfile" + +// testFileLibPath builds a `testfile://` library URL for the given absolute +// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading +// slash after ToSlash, which makes url.Parse treat the drive letter as a +// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the +// registered constructor below strips that leading slash back off. +func testFileLibPath(absPath string) string { + p := filepath.ToSlash(absPath) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return testFileScheme + "://" + p +} + +func init() { + // Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by + // integration tests that need real files but not the taglib extractor. + storage.Register(testFileScheme, func(u url.URL) storage.Storage { + root := u.Path + // Undo the leading slash added by testFileLibPath on Windows so that + // os.Stat / os.DirFS receive a native path like `C:\foo`. + if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' { + root = root[1:] + } + return &osDirStorage{root: filepath.FromSlash(root)} + }) +} + +type osDirStorage struct{ root string } + +func (s *osDirStorage) FS() (storage.MusicFS, error) { + if _, err := os.Stat(s.root); err != nil { + return nil, err + } + return osDirFS{os.DirFS(s.root)}, nil +} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go new file mode 100644 index 000000000..3d5523afd --- /dev/null +++ b/core/artwork/e2e/album_test.go @@ -0,0 +1,354 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + +var _ = Describe("Album artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("an album has a single folder with cover.jpg at the album root", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← matched by cover.* + It("returns the album-root cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder + // files first. Flip from PIt to It once it prefers shorter/parent paths. + When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← currently wins (bug) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg + // └── cover.jpg ← should win (album-root fallback) + PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/cover.jpg": imageFile("disc1"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(al.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two disc subfolders as one multi-disc album") + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; + // the lexicographic full-path tiebreaker in compareImageFiles ranks + // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". + // Flip from PIt to It once compareImageFiles prefers shorter/parent paths. + When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← currently wins (bug) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── folder.jpg ← should win (album-root fallback) + PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/folder.jpg": imageFile("album-root"), + "Artist/Album/CD1/folder.jpg": imageFile("disc1"), + "Artist/Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder + // lookup whenever an album lives entirely under a single subfolder, so an + // album-root cover is never considered. Flip from PIt to It once the guard + // accepts single-folder albums whose parent isn't already in the folder set. + When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ └── 01 - Track.mp3 + // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) + PIt("uses the parent-folder cover (currently ignored — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded") + // └── cover.jpg + It("returns the embedded image", func() { + conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + "Artist/Album/cover.jpg": imageFile("external"), + }) + scan() + // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("CoverArtPriority lists external first but no external file is present", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded") + It("falls through to embedded artwork", func() { + conf.Server.CoverArtPriority = "external, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("the only cover file uses uppercase extension and a different case in its name", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── Cover.JPG ← matched case-insensitively by cover.* + It("matches case-insensitively against cover.*", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/Cover.JPG": imageFile("case-insensitive"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) + }) + }) + + When("two cover files have basenames that tie under the natural-sort tiebreaker", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // └── cover.1.jpg + It("prefers the file without a numeric suffix", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("secondary"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("the album has no cover and CoverArtPriority lists only file patterns", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) + It("returns ErrUnavailable", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) + Expect(err).To(HaveOccurred()) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#albums + // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external". + When("only folder.jpg is present (cover.* and front.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── folder.jpg ← matched by folder.* + It("falls through to folder.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("only front.jpg is present (cover.* and folder.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── front.jpg ← matched by front.* + It("falls through to front.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) + }) + }) + + When("cover.*, folder.*, and front.* all exist in the same folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (cover.* is first in priority) + // ├── folder.jpg + // └── front.jpg + It("prefers cover.* (first in CoverArtPriority)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("only folder.* and front.* exist (priority order check)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── folder.jpg ← wins (folder.* comes before front.*) + // └── front.jpg + It("prefers folder.* over front.*", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("three cover files tie by basename and differ only by numeric suffix", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // ├── cover.1.jpg + // └── cover.2.jpg + It("selects the unsuffixed file first regardless of numeric-suffix order", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.2.jpg": imageFile("second"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("first"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("CoverArtPriority contains an unknown pattern before a matching one", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← wins (unknown "bogus.*" is skipped) + It("skips the unknown pattern and falls through to the matching one", func() { + conf.Server.CoverArtPriority = "bogus.*, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("embedded is first in CoverArtPriority but the track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (no embedded picture) + // └── cover.jpg ← wins (embedded skipped, falls through) + It("falls through to the next priority entry", func() { + conf.Server.CoverArtPriority = "embedded, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go new file mode 100644 index 000000000..d959b1d60 --- /dev/null +++ b/core/artwork/e2e/artist_test.go @@ -0,0 +1,167 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#artists +// Default ArtistArtPriority is "artist.*, album/artist.*, external". +var _ = Describe("Artist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the artist folder contains an artist.jpg", func() { + // Artist/ + // ├── artist.jpg ← matched by artist.* + // └── Album/ + // └── 01 - Track.mp3 + It("returns the artist.* image from the artist folder", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("artist.* only exists inside an album folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("falls through to album/artist.* and returns that image", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("both the artist folder and an album folder have an artist.* image", func() { + // Artist/ + // ├── artist.jpg ← wins (artist.* before album/artist.*) + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg + It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("an artist has an uploaded image and a matching artist.* file", func() { + // / + // └── artwork/ + // └── artist/ + // └── _upload.jpg ← wins (uploaded image beats the priority chain) + // Library: + // Artist/ + // ├── artist.jpg (ignored — uploaded image comes first) + // └── Album/ + // └── 01 - Track.mp3 + It("prefers the uploaded image over any priority-chain match", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + ar := soleArtist() + + uploaded := ar.ID + "_upload.jpg" + writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) + ar.UploadedImage = uploaded + Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) + + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) + }) + }) + + When("ArtistArtPriority uses album/ (not just album/artist.*)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("resolves the pattern against the artist's album image files", func() { + conf.Server.ArtistArtPriority = "album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { + // / + // └── Artist.jpg ← matched by artist name (image-folder source) + // Library: + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no artist.* present in library) + It("returns the image from the configured artist image folder", func() { + imgFolder := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) + conf.Server.ArtistImageFolder = imgFolder + conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" + + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) + }) + }) +}) + +func soleArtist() model.Artist { + GinkgoHelper() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"artist.name": "Artist"}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(artists) == 0 { + Fail("sole artist not found") + return model.Artist{} + } + return artists[0] +} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go new file mode 100644 index 000000000..7569cbc32 --- /dev/null +++ b/core/artwork/e2e/disc_test.go @@ -0,0 +1,276 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Disc artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the album is single-disc with a disc1.jpg in the only folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── disc1.jpg ← matched by disc*.* + It("returns the disc1.jpg image (matched as disc*.*)", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc1-image"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) + }) + }) + + When("the album has no per-disc image and no album cover", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) + It("returns ErrUnavailable for the disc lookup", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + _, err := readArtworkOrErr(discID) + Expect(err).To(HaveOccurred()) + }) + }) + + When("the album has no per-disc image but has an album cover", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← album-level fallback (no disc art present) + It("falls back to the album cover", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("multiple disc images exist in the same folder (disc1 vs disc10)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── disc1.jpg ← matches request for disc 1 + // └── disc10.jpg + It("matches the requested disc number, not a higher-numbered one", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc-one"), + "Artist/Album/disc10.jpg": imageFile("disc-ten"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) + }) + }) + + When("a multi-disc album has per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg ← matches request for disc 1 + // └── CD2/ + // ├── 01 - Track.mp3 + // └── disc2.jpg ← matches request for disc 2 + It("returns the requested disc's image", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art + // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded". + When("a disc subfolder has a cd2.png image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cd2.png ← matched by cd*.* for disc 2 + It("matches via the cd*.* pattern", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← matched by cover.* inside disc folder + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("falls through to cover.* inside the disc folder", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) + }) + }) + + When("DiscArtPriority is the empty string", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg (ignored — DiscArtPriority is empty) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cd2.png (ignored — DiscArtPriority is empty) + // └── cover.jpg ← used for every disc (album-level fallback) + It("skips every disc-level source and returns the album cover", func() { + conf.Server.DiscArtPriority = "" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + for _, n := range []int{1, 2} { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), + "disc %d should use the album cover when DiscArtPriority is empty", n) + } + }) + }) + + When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ ├── disc1.jpg ← matched by disc*.* for disc 1 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // ├── disc2/ + // │ ├── cd2.png ← matched by cd*.* for disc 2 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // └── cover.jpg (album-level fallback, unused here) + It("matches the per-disc image for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), + "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), + "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), + "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/disc2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) + Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword + It("selects the subtitle-named image", func() { + conf.Server.DiscArtPriority = "discsubtitle" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) + }) + }) + + When("discsubtitle is set but no image filename matches the subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── cover.jpg ← wins (discsubtitle has no match, falls through) + It("falls through to the next priority entry", func() { + conf.Server.DiscArtPriority = "discsubtitle, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go new file mode 100644 index 000000000..e3abca097 --- /dev/null +++ b/core/artwork/e2e/helpers_test.go @@ -0,0 +1,184 @@ +package artworke2e_test + +import ( + "bytes" + "context" + _ "embed" + "errors" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "net/url" + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that +// contains a valid MP3 stream with an embedded picture. Used in the +// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't +// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the +// scanner still populates EmbedArtPath via the JSON-tagged track, and the +// artwork reader gets real bytes when it calls libFS.Open. +// +//go:embed testdata/embedded_art.mp3 +var realMP3WithEmbeddedArt []byte + +// embeddedArtBytes is the exact image payload that the artwork reader will +// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can +// assert byte-for-byte equality — if this ever differs it means the reader +// pulled from a different source. +var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative +// path so libFS.Open returns an MP3 stream taglib can parse. +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} +} + +// placeholderBytes returns the bundled album-placeholder image bytes — the +// same stream the artwork reader emits when every source falls through. +func placeholderBytes() []byte { + GinkgoHelper() + r, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// writeUploadedImage drops `filename` into /artwork// with +// the given bytes, matching the on-disk layout expected by +// model.UploadedImagePath. +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dir := filepath.Dir(model.UploadedImagePath(entity, filename)) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) +} + +func newNoopFFmpeg() *tests.MockFFmpeg { + ff := tests.NewMockFFmpeg("") + ff.Error = errors.New("noop") + return ff +} + +// trackFile builds a FakeFS MP3 entry with optional tag overrides. +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// imageFile builds a label-keyed image entry. The bytes are deterministic +// per-label so tests can assert which file won. +func imageFile(label string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("image:" + label)} +} + +// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by +// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). +func realPNG(label string) *fstest.MapFile { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + // Derive a deterministic color per label. + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +// imageBytes returns the bytes that imageFile(label) writes. +func imageBytes(label string) []byte { return imageFile(label).Data } + +// setLayout populates fakeFS with the given map. Call after setupHarness. +// All paths must be forward-slash and relative (no leading "/"). +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func readArtwork(artID model.ArtworkID) []byte { + GinkgoHelper() + r, _, err := aw.Get(ctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return b +} + +func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { + r, _, err := aw.Get(ctx, artID, 0, false) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// noopProvider implements external.Provider with not-found returns so the +// "external" priority entry never produces a result. +type noopProvider struct{} + +func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go new file mode 100644 index 000000000..1f43a3827 --- /dev/null +++ b/core/artwork/e2e/mediafile_test.go @@ -0,0 +1,110 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles +// Navidrome resolves mediafile artwork in this order: +// 1. Embedded image from the mediafile itself +// 2. For multi-disc albums, disc-level artwork +// 3. Album cover art +// +// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) +// is covered by the existing embedded-art album tests (which currently +// Skip under FakeFS). The tests below cover (2) and (3): the fallback +// chain for tracks without embedded art. +var _ = Describe("MediaFile artwork fallback", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a multi-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 ← track requested + // │ └── disc2.jpg ← wins (disc-level before album-level) + // └── cover.jpg + It("falls back to the disc-level artwork (not the album cover)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) + }) + }) + + When("a single-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (album-level fallback, no disc subfolder) + It("falls back to the album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + mf := mediafileOn("Artist/Album/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ └── 01 - Track.mp3 + // ├── CD2/ + // │ └── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (no disc image → album-level fallback) + It("falls through from disc to album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) +}) + +func mediafileOn(relPath string) model.MediaFile { + GinkgoHelper() + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Like{"media_file.path": relPath}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(mfs) == 0 { + Fail("mediafile not found: " + relPath) + return model.MediaFile{} + } + return mfs[0] +} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go new file mode 100644 index 000000000..d28efca8e --- /dev/null +++ b/core/artwork/e2e/playlist_test.go @@ -0,0 +1,158 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Playlist artwork resolves in this priority order: +// 1. Uploaded image (/artwork/playlist/) +// 2. Sidecar image next to the .m3u file (same basename, any image ext) +// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) +// 4. Generated 2x2 tiled cover from the playlist's albums +// 5. Album placeholder image +// +// The library FS is FakeFS, but uploaded/sidecar/local-external images are +// real files on disk — the reader reads them via os.Open, so the tests +// place them in a real tempdir under DataFolder. +var _ = Describe("Playlist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a playlist has an uploaded image", func() { + // / + // └── artwork/ + // └── playlist/ + // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) + + pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) + }) + }) + + When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() { + // / + // ├── MyList.m3u + // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive) + It("returns the sidecar image", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) + }) + }) + + When("a playlist's sidecar uses a different extension case", func() { + // / + // ├── MyList.m3u + // └── MyList.PNG ← matched case-insensitively + It("matches case-insensitively", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) + }) + }) + + When("a playlist has an ExternalImageURL pointing to a local file", func() { + // / + // └── cover.jpg ← absolute path stored in ExternalImageURL + It("returns the local file regardless of EnableM3UExternalAlbumArt", func() { + conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) + }) + }) + + When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { + // (no local files — http source is gated off, reader falls through to placeholder) + It("skips the URL and falls through to the bundled placeholder", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no images and no tracks", func() { + // (reader falls all the way through to the bundled album placeholder) + It("returns the album placeholder", func() { + pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { + // Library: + // Artist/ + // ├── AlbumA/ + // │ ├── 01 - Track.mp3 + // │ └── cover.png (real PNG — wins as tile 1 source) + // └── AlbumB/ + // ├── 01 - Track.mp3 + // └── cover.png (real PNG — wins as tile 2 source) + // Playlist "pl-7" references tracks from both albums, so the reader + // generates a 2x2 tiled cover from 2 distinct album art tiles (the + // tiled generator mirrors when it has fewer than 4 unique tiles). + It("generates a tiled cover from album art", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), + "Artist/AlbumA/cover.png": realPNG("albumA"), + "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), + "Artist/AlbumB/cover.png": realPNG("albumB"), + }) + scan() + + // Pull the scanned mediafile IDs so we can attach them to the playlist. + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(2)) + + pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} + pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + + data := readArtwork(pl.CoverArtID()) + // The tiled cover is a PNG-encoded 600x600 image (tileSize const). + // Exact bytes vary (random album order), so assert format + non-trivial size. + Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) + Expect(len(data)).To(BeNumerically(">", 1000)) + }) + }) +}) + +func putPlaylist(pl model.Playlist) model.Playlist { + GinkgoHelper() + if pl.OwnerID == "" { + pl.OwnerID = "admin-1" + } + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + return pl +} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go new file mode 100644 index 000000000..73ee5f377 --- /dev/null +++ b/core/artwork/e2e/radio_test.go @@ -0,0 +1,42 @@ +package artworke2e_test + +import ( + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a radio has an uploaded image", func() { + // / + // └── artwork/ + // └── radio/ + // └── rd-1_logo.jpg ← matched by UploadedImagePath() + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) + + rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) + }) + }) + + When("a radio has no uploaded image", func() { + // (no files on disk — reader has no sources to fall back to) + It("returns ErrUnavailable", func() { + rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + _, err := readArtworkOrErr(artID) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go new file mode 100644 index 000000000..9ce0edb8b --- /dev/null +++ b/core/artwork/e2e/suite_test.go @@ -0,0 +1,106 @@ +package artworke2e_test + +import ( + "context" + "path/filepath" + "testing" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork E2E Suite") +} + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +var ( + ctx context.Context + ds *tests.MockDataStore + aw artwork.Artwork + fakeFS *storagetest.FakeFS +) + +// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps +// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup +// can't unlink a file with a live handle on Windows. A suite-level tempdir +// combined with an AfterSuite close avoids the lock conflict. +var suiteDBTempDir string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() +}) + +var _ = AfterSuite(func() { + db.Close(GinkgoT().Context()) +}) + +func setupHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + // Reuse the suite-level DB path so the singleton connection keeps working + // across specs (see suiteDBTempDir comment). + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = tempDir + conf.Server.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call + conf.Server.EnableExternalServices = false + + db.Db().SetMaxOpenConns(1) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + db.Init(ctx) + DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) +} + +func scan() { + GinkgoHelper() + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..18cb906749e047afda734b62c1ffcdd89ca72ead GIT binary patch literal 64223 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 z zLa|e2*E8IY%8hb0d-W}^T}0X@swcrwwE)V!=4oR=SPotxM#HfcQI}{qxDN{Op+%ux z9m?7;aSqM2Z|R2L^`!&~XZT)ko-OfLG|xOOw2pI(Q&v^>nQ>XjKX%?-e^^UBsiAt4 zt|DMzGclng7VOvQ`>cc{_Fm$>_fYfEdS^lE?3wR4UeNZ{hj*s&oF#=H96M zntPksekSA~QG#PkAyg_5s#`A*2r2>9h$AIIs|aEks2&86BH;iw+M7Ou9YEf>V?^A0 zyB9Iv;%Ta-RqTf-=~r(Vz3j{$n;_jj%!s{<4W*0r>zQ2O{%RM=d~uMdS453HO6w(} zB6)W|Na-<)43W_B$PMpMaO-rEMmNoDr)wO}yt=`_@X*rb4fN?|%7lwuoASLe$#)`| z9M^3&e57BI_jOPBnZxiPPB9&LRM?>&p*1ja0C307f&fTyEQ%fhY6mnS{(3({(3fPt zl&J}OAHvU@an*QYO6Rn}1fv;rgPyKKh`MfV>rgQIrD{fRrRsT9F{iNLyQ=2o+I-0; zhY>x_$20R^12Sc}9~343iz^omK&E_E!Ut$QGlsc<0PrINf}aQ?gj<&pW9eVwMs)

9+GI1 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 literal 0 HcmV?d00001 diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go new file mode 100644 index 000000000..ff557294e --- /dev/null +++ b/core/artwork/library_fs.go @@ -0,0 +1,44 @@ +package artwork + +import ( + "context" + "path/filepath" + + "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/model" +) + +// libraryView bundles the MusicFS for a library with its absolute root path, +// so readers can open library-relative paths through FS and compose absolute +// paths (for ffmpeg, which is path-based) via Abs. +type libraryView struct { + FS storage.MusicFS + absRoot string +} + +// Abs returns the absolute path for a library-relative path. Returns "" for an +// empty rel so callers (fromFFmpegTag) can treat it as "no path available". +func (v libraryView) Abs(rel string) string { + if rel == "" { + return "" + } + return filepath.Join(v.absRoot, rel) +} + +// loadLibraryView resolves the MusicFS and absolute root path in a single +// library lookup. +func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) { + lib, err := ds.Library(ctx).Get(libID) + if err != nil { + return libraryView{}, err + } + s, err := storage.For(lib.Path) + if err != nil { + return libraryView{}, err + } + fs, err := s.FS() + if err != nil { + return libraryView{}, err + } + return libraryView{FS: fs, absRoot: lib.Path}, nil +} diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go new file mode 100644 index 000000000..acf08fda3 --- /dev/null +++ b/core/artwork/library_fs_test.go @@ -0,0 +1,45 @@ +package artwork + +import ( + "context" + + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadLibraryView", Ordered, func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeAll(func() { + storagetest.Register("fake", &storagetest.FakeFS{}) + }) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}} + }) + + It("returns a view for a library backed by registered storage", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) + + lib, err := loadLibraryView(ctx, ds, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(lib.FS).ToNot(BeNil()) + Expect(lib.absRoot).To(Equal("fake:///music")) + }) + + It("returns an error when the library does not exist", func() { + _, err := loadLibraryView(ctx, ds, 999) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when the library path uses an unregistered scheme", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) + _, err := loadLibraryView(ctx, ds, 2) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 35d489b6c..8d7e14fd0 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -7,14 +7,13 @@ import ( "errors" "fmt" "io" - "path/filepath" + "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" @@ -24,12 +23,12 @@ import ( type albumArtworkReader struct { cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string - rootFolder string + a *artwork + provider external.Provider + album model.Album + updatedAt *time.Time + imgFiles []string // library-relative, forward-slash, no leading slash + lib libraryView } func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { @@ -41,13 +40,17 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) + if err != nil { + return nil, err + } a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""), + a: artwork, + provider: provider, + album: *al, + updatedAt: imagesUpdateAt, + imgFiles: imgFiles, + lib: lib, } a.cacheKey.artID = artID if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { @@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath) - ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath)) + embedRel := a.album.EmbedArtPath + ff = append(ff, + fromTag(ctx, a.lib.FS, embedRel), + fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), + ) case pattern == "external": ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern)) + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) } } return ff @@ -132,13 +138,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo var imgFiles []string var updatedAt time.Time for _, f := range folders { - path := f.AbsolutePath() - paths = append(paths, path) + paths = append(paths, f.AbsolutePath()) if f.ImagesUpdatedAt.After(updatedAt) { updatedAt = f.ImagesUpdatedAt } + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") for _, img := range f.ImageFiles { - imgFiles = append(imgFiles, filepath.Join(path, img)) + imgFiles = append(imgFiles, path.Join(rel, img)) } } @@ -179,8 +185,8 @@ func compareImageFiles(a, b string) int { b = strings.ToLower(b) // Extract base filenames without extensions - baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) - baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b)) + baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) + baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) // Compare base names first, then full paths if equal return cmp.Or( diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index a8a0eae3e..03412b6d9 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,7 +3,6 @@ package artwork import ( "context" "errors" - "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() { // Files should be sorted by base filename without extension, then by full path // "back" < "cover", so back.jpg comes first // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) - Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) - Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) + Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) + Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) }) It("prioritizes files without numeric suffixes", func() { @@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) }) It("handles case-insensitive sorting", func() { @@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) }) It("includes images from parent folder for multi-disc albums", func() { @@ -151,8 +150,8 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(*imagesUpdatedAt).To(Equal(expectedAt)) Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) }) It("does not query parent when parent ID is already in album folders", func() { @@ -179,7 +178,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (parent already in folder set) Expect(repo.getCallCount).To(Equal(0)) }) @@ -209,7 +208,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) // Get should not have been called (different parents) Expect(repo.getCallCount).To(Equal(0)) }) @@ -232,7 +231,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (single folder, no parent lookup) Expect(repo.getCallCount).To(Equal(0)) }) @@ -290,7 +289,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) Expect(repo.getCallCount).To(Equal(1)) }) }) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 96ba08b8f..37b7b6dee 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "slices" "strings" @@ -35,6 +36,7 @@ type artistReader struct { artistFolder string imgFiles []string imgFolderImgPath string // cached path from ArtistImageFolder lookup + lib libraryView } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A if err != nil { return nil, err } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) + if err != nil { + return nil, err + } + } a := &artistReader{ a: artwork, provider: provider, artist: *ar, artistFolder: artistFolder, imgFiles: imgFiles, + lib: lib, } // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can // change _after_ retrieving from external sources, making the key invalid @@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin case pattern == "image-folder": ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): - ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + if a.lib.FS != nil { + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + } default: - ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern)) + ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) } } return ff } -func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { +// fromArtistFolder walks up from artistFolder toward libPath looking for a +// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth +// and the library root: once we reach libPath (or if artistFolder is outside +// libPath), the walk stops. All reads go through libFS, which keeps artwork +// resolution scoped to the configured library. +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may + // return backslash separators on Windows. + rel = filepath.ToSlash(rel) current := artistFolder for range maxArtistFolderTraversalDepth { - if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { - return reader, path, nil + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil } - - parent := filepath.Dir(current) - if parent == current { - break + if rel == "." { + break // reached library root; don't traverse above it } - current = parent + rel = path.Dir(rel) + current = filepath.Dir(current) } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) } } -func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder) - fsys := os.DirFS(folder) - matches, err := fs.Glob(fsys, pattern) +// findImageInFolder globs libFS at relFolder for pattern and returns the first +// matching image. absFolder is used only for the returned display path and log +// messages so callers see absolute-looking paths consistent with the rest of +// the artwork pipeline. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err) + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) return nil, "", err } @@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos // suffixes (e.g., artist.jpg before artist.1.jpg) slices.SortFunc(imagePaths, compareImageFiles) - // Try to open files in sorted order for _, p := range imagePaths { - filePath := filepath.Join(folder, p) - f, err := os.Open(filePath) + f, err := libFS.Open(p) if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + log.Warn(ctx, "Could not open cover art file", "file", p, err) continue } - return f, filePath, nil + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() } func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 33dc6ed57..e2a1f2094 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "io/fs" "os" "path/filepath" "time" @@ -117,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() { var ( ctx context.Context tempDir string + libFS fs.FS testFunc sourceFunc ) BeforeEach(func() { ctx = context.Background() tempDir = GinkgoT().TempDir() + libFS = os.DirFS(tempDir) }) When("artist folder contains matching image", func() { @@ -134,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds and returns the image", func() { @@ -151,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() { }) }) + When("artist folder name contains glob metacharacters", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "Artist [Live]") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("treats the folder path literally when globbing through the library fs", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("bracketed artist image")) + reader.Close() + }) + }) + When("artist folder is empty but parent contains image", func() { BeforeEach(func() { // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ @@ -163,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(parentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in parent directory", func() { @@ -191,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(grandparentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in grandparent directory", func() { @@ -220,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("prioritizes the closest (artist folder) image", func() { @@ -246,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns the first valid image file in sorted order", func() { @@ -273,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { @@ -301,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "*.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") }) It("sorts case-insensitively", func() { @@ -327,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() { // Create non-matching files Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns an error", func() { @@ -346,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() { artistDir := filepath.Join(tempDir, "artist") Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("handles root boundary gracefully", func() { @@ -367,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() { restrictedFile := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("logs warning and continues searching", func() { @@ -397,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds artist.jpg in artist folder for single album artist", func() { diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 30d4968e1..de0a765f0 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -5,7 +5,7 @@ import ( "crypto/md5" "fmt" "io" - "os" + "path" "path/filepath" "strconv" "strings" @@ -13,7 +13,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,10 +23,11 @@ type discArtworkReader struct { a *artwork album model.Album discNumber int - imgFiles []string - discFolders map[string]bool + imgFiles []string // library-relative, forward-slash, no leading slash + discFoldersRel map[string]bool // library-relative folder paths isMultiFolder bool - firstTrackPath string + firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs + lib libraryView updatedAt *time.Time } @@ -57,18 +57,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - // Build disc folder set and find first track - discFolders := make(map[string]bool) - var firstTrackPath string + lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + if err != nil { + return nil, err + } + + // Build disc folder set and find first track. mf.Path is already library-relative. + var firstTrackRel string allFolderIDs := make(map[string]bool) for _, mf := range mfs { allFolderIDs[mf.FolderID] = true - if firstTrackPath == "" { - firstTrackPath = mf.Path + if firstTrackRel == "" { + firstTrackRel = filepath.ToSlash(mf.Path) } } - // Resolve folder IDs to absolute paths + // Resolve folder IDs to library-relative paths + discFoldersRel := make(map[string]bool) if len(allFolderIDs) > 0 { folderIDs := make([]string, 0, len(allFolderIDs)) for id := range allFolderIDs { @@ -81,7 +86,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } for _, f := range folders { - discFolders[f.AbsolutePath()] = true + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") + discFoldersRel[rel] = true } } @@ -92,9 +98,10 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID album: *al, discNumber: discNumber, imgFiles: imgFiles, - discFolders: discFolders, + discFoldersRel: discFoldersRel, isMultiFolder: isMultiFolder, - firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath), + firstTrackRel: firstTrackRel, + lib: lib, updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID @@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath)) + ff = append(ff, + fromTag(ctx, d.lib.FS, d.firstTrackRel), + fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)), + ) case pattern == "external": // Not supported for disc art, silently ignore case pattern == "discsubtitle": @@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range d.imgFiles { - _, name := filepath.Split(file) - stem := strings.TrimSuffix(name, filepath.Ext(name)) + name := path.Base(file) + stem := strings.TrimSuffix(name, path.Ext(name)) if !strings.EqualFold(stem, subtitle) { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -214,8 +224,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string return func() (io.ReadCloser, string, error) { var fallbacks []string for _, file := range d.imgFiles { - _, name := filepath.Split(file) - name = strings.ToLower(name) + name := strings.ToLower(path.Base(file)) match, err := filepath.Match(pattern, name) if err != nil { log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) @@ -230,7 +239,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string if num != d.discNumber { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -239,14 +248,14 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string } } - if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] { + if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] { continue } fallbacks = append(fallbacks, file) } for _, file := range fallbacks { - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index 7b633342f..8264ee27b 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -74,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + // createFile creates the file on disk and returns its library-relative forward-slash path. + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath + } + + // removeFile removes a library-relative file from disk. + removeFile := func(relPath string) { + Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed()) } It("matches file with disc number in single-folder album", func() { f1 := createFile("album/disc1.jpg") f2 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -101,9 +108,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("matches file without number in single-folder album (shared disc art)", func() { f1 := createFile("album/cover.png") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.*") @@ -118,9 +126,10 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/shellac.png") makeReader := func(discNum int) *discArtworkReader { return &discArtworkReader{ - discNumber: discNum, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNum, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } } @@ -139,9 +148,10 @@ var _ = Describe("Disc Artwork Reader", func() { f2 := createFile("album/disc1.jpg") f3 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1, f2, f3}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 2, + imgFiles: []string{f1, f2, f3}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -163,9 +173,10 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/cover.png") f2 := createFile("album/disc1.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") @@ -191,9 +202,10 @@ var _ = Describe("Disc Artwork Reader", func() { createFile("album/disc2.jpg"), } reader := &discArtworkReader{ - discNumber: discNumber, - imgFiles: files, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -210,12 +222,13 @@ var _ = Describe("Disc Artwork Reader", func() { It("tries the next fallback candidate when the first one cannot be opened", func() { f1 := createFile("album/cover.jpg") f2 := createFile("album/cover.png") - // Remove f1 so os.Open will fail on it; f2 should still win. - Expect(os.Remove(f1)).To(Succeed()) + // Remove f1 so Open will fail on it; f2 should still win. + removeFile(f1) reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.*") @@ -234,15 +247,16 @@ var _ = Describe("Disc Artwork Reader", func() { // that first file is unreadable. f1 := createFile("album/stale/cover.png") f2 := createFile("album/cover.png") - Expect(os.Remove(f1)).To(Succeed()) + removeFile(f1) reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, - discFolders: map[string]bool{ - filepath.Join(tmpDir, "album"): true, - filepath.Join(tmpDir, "album/stale"): true, + discFoldersRel: map[string]bool{ + "album": true, + "album/stale": true, }, isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.png") @@ -260,9 +274,10 @@ var _ = Describe("Disc Artwork Reader", func() { createFile("album/disc2.jpg"), } reader := &discArtworkReader{ - discNumber: discNumber, - imgFiles: files, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, pattern) @@ -282,10 +297,11 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/cd1/disc.jpg") f2 := createFile("album/cd2/disc.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -300,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", func() { // disc2.jpg in cd1 folder should match disc 2, not disc 1 f1 := createFile("album/cd1/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 2, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -317,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("does not match disc2.jpg when looking for disc 1", func() { f1 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -339,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath } It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() { @@ -351,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -366,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 2, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks") @@ -381,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -394,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -407,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() { Describe("discArtworkReader", func() { Describe("fromDiscArtPriority", func() { - var reader *discArtworkReader + var ( + reader *discArtworkReader + tmpDir string + ) BeforeEach(func() { + tmpDir = GinkgoT().TempDir() reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFolders: map[string]bool{"/music/album/cd2": true}, + discNumber: 2, + isMultiFolder: true, + discFoldersRel: map[string]bool{"music/album/cd2": true}, imgFiles: []string{ - "/music/album/cd1/disc.jpg", - "/music/album/cd2/disc.jpg", - "/music/album/cd2/disc2.jpg", + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", }, - firstTrackPath: "/music/album/cd2/track1.flac", + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } }) diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index cf25c8f5d..eac3c5e70 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -15,6 +15,7 @@ type mediafileArtworkReader struct { a *artwork mediafile model.MediaFile album model.Album + lib libraryView } func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { @@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, + lib: lib, } a.cacheKey.artID = artID a.cacheKey.lastUpdate = mf.UpdatedAt @@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time { func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { var ff []sourceFunc if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - path := a.mediafile.AbsolutePath() ff = []sourceFunc{ - fromTag(ctx, path), - fromFFmpegTag(ctx, a.a.ffmpeg, path), + fromTag(ctx, a.lib.FS, a.mediafile.Path), + fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), } } // For multi-disc albums, fall back to disc artwork first; for single-disc albums, diff --git a/core/artwork/sources.go b/core/artwork/sources.go index d830593fc..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -5,9 +5,9 @@ import ( "context" "fmt" "io" + "io/fs" "net/http" "net/url" - "os" "path/filepath" "reflect" "regexp" @@ -53,7 +53,7 @@ func (f sourceFunc) String() string { return name } -func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc { +func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range files { _, name := filepath.Split(file) @@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc if !match { continue } - f, err := os.Open(file) + f, err := libFS.Open(file) if err != nil { log.Warn(ctx, "Could not open cover art file", "file", file, err) continue } - return f, file, err + return f, file, nil } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } @@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{ regexp.MustCompile(`(?i).*cover.*`), } -func fromTag(ctx context.Context, path string) sourceFunc { +func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { return func() (io.ReadCloser, string, error) { - if path == "" { + if relPath == "" { return nil, "", nil } - f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast)) + f, err := libFS.Open(relPath) if err != nil { return nil, "", err } + rs, ok := f.(io.ReadSeeker) + if !ok { + f.Close() + return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath) + } + tf, err := taglib.OpenStream(rs, + taglib.WithReadStyle(taglib.ReadStyleFast), + taglib.WithFilename(relPath), + ) + if err != nil { + f.Close() + return nil, "", err + } + // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() + defer tf.Close() - images := f.Properties().Images + images := tf.Properties().Images if len(images) == 0 { - return nil, "", fmt.Errorf("no embedded image found in %s", path) + return nil, "", fmt.Errorf("no embedded image found in %s", relPath) } - imageIndex := findBestImageIndex(ctx, images, path) - data, err := f.Image(imageIndex) + imageIndex := findBestImageIndex(ctx, images, relPath) + data, err := tf.Image(imageIndex) if err != nil || len(data) == 0 { - return nil, "", fmt.Errorf("could not load embedded image from %s", path) + return nil, "", fmt.Errorf("could not load embedded image from %s", relPath) } - return io.NopCloser(bytes.NewReader(data)), path, nil + return io.NopCloser(bytes.NewReader(data)), relPath, nil } } @@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str return 0 } +// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess +// and cannot read from arbitrary fs.FS implementations; piping via stdin is a +// non-trivial refactor with stream/seek implications. +// +// TODO(artwork-musicfs): when the storage backing the library is not local +// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source +// func to return (nil, "", nil) so callers fall through cleanly. func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { return func() (io.ReadCloser, string, error) { if path == "" { diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go new file mode 100644 index 000000000..4282575a5 --- /dev/null +++ b/core/artwork/sources_internal_test.go @@ -0,0 +1,92 @@ +package artwork + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("fromExternalFile", func() { + It("opens a matching file via the library FS", func() { + fsys := fstest.MapFS{ + "Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("cover-bytes"))) + Expect(path).To(Equal("Artist/Album/cover.jpg")) + }) + + It("returns an error when no file matches", func() { + fsys := fstest.MapFS{ + "Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*") + _, _, err := f() + Expect(err).To(HaveOccurred()) + }) + + It("skips files that fail to open and tries the next match", func() { + fsys := fstest.MapFS{ + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + // "missing/cover.jpg" is in candidates but not in the FS — should be skipped. + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("a"))) + Expect(path).To(Equal("a/cover.jpg")) + }) +}) + +var _ = Describe("fromTag", func() { + It("opens an embedded image via fs.FS", func() { + fsys := os.DirFS("tests/fixtures/artist/an-album") + f := fromTag(GinkgoT().Context(), fsys, "test.mp3") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("test.mp3")) + b, _ := io.ReadAll(r) + Expect(b).ToNot(BeEmpty()) + }) + + It("returns nil reader when the relative path is empty", func() { + f := fromTag(GinkgoT().Context(), os.DirFS("."), "") + r, _, err := f() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + }) + + It("errors when the FS file is not seekable", func() { + fsys := nonSeekableFS{data: []byte("garbage")} + f := fromTag(GinkgoT().Context(), fsys, "x.mp3") + _, _, err := f() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not seekable")) + }) +}) + +// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file. +type nonSeekableFS struct{ data []byte } + +func (n nonSeekableFS) Open(name string) (fs.File, error) { + return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil +} + +type nonSeekableFile struct{ r *bytes.Reader } + +func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) } +func (n *nonSeekableFile) Close() error { return nil } +func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index abeda5c9e..80790c8d6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -57,6 +58,11 @@ func New() FFmpeg { return &ffmpeg{} } +// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the +// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to +// detect this specific case and fall back to static resize. +var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp") + const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" @@ -86,6 +92,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max if err != nil { return nil, err } + if !animWebP.has(cmdPath, "libwebp_anim") { + return nil, ErrAnimatedWebPUnsupported + } args := []string{cmdPath, "-i", "pipe:0"} if maxSize > 0 { @@ -98,6 +107,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max return e.start(ctx, args, reader) } +// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word +// match of encoder name. The output has rows like " V....D libwebp_anim ..." +// where the name is the 2nd whitespace-separated field. +func parseEncodersOutput(out []byte, name string) bool { + for line := range strings.SplitSeq(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == name { + return true + } + } + return false +} + func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err @@ -538,6 +560,49 @@ func ffmpegCmd() (string, error) { return ffmpegPath, ffmpegErr } +type encoderProbeState uint8 + +const ( + encoderProbeUnknown encoderProbeState = iota + encoderProbeAvailable + encoderProbeUnavailable +) + +type encoderProbe struct { + mu sync.Mutex + state encoderProbeState +} + +func (p *encoderProbe) has(cmdPath, encoder string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.state { + case encoderProbeAvailable: + return true + case encoderProbeUnavailable: + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec + if err != nil { + log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err) + return false + } + + if parseEncodersOutput(out, encoder) { + p.state = encoderProbeAvailable + return true + } + + p.state = encoderProbeUnavailable + log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images", + "path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)") + return false +} + // These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead. var ( ffOnce sync.Once @@ -545,4 +610,5 @@ var ( ffmpegErr error probeOnce sync.Once probeAvail bool + animWebP encoderProbe ) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 01b284172..1649015d9 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,8 +3,10 @@ package ffmpeg import ( "context" "os" + "os/exec" "path/filepath" "runtime" + "strings" sync "sync" "testing" "time" @@ -693,4 +695,57 @@ var _ = Describe("ffmpeg", func() { }) }) }) + + Describe("parseEncodersOutput", func() { + const sample = `Encoders: + V..... = Video + ------ + V....D apng APNG (Animated Portable Network Graphics) image + V....D libwebp_anim libwebp WebP image (codec webp) + V....D libwebp libwebp WebP image (codec webp) + A....D aac AAC (Advanced Audio Coding) +` + It("returns true when the encoder is present", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue()) + }) + It("returns false when the encoder is absent", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse()) + }) + It("does not match partial names", func() { + // libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word. + stripped := `Encoders: + V....D libwebp libwebp WebP image (codec webp) +` + Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse()) + }) + It("handles empty output", func() { + Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse()) + }) + }) + + Describe("ConvertAnimatedImage", func() { + // Point ffmpegCmd at a stand-in binary that produces empty `-encoders` + // output so hasAnimatedWebPEncoder returns false. /usr/bin/true is + // portable across POSIX systems. + It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() { + truePath, err := exec.LookPath("true") + if err != nil { + Skip("true(1) not available") + } + origPath, origErr := ffmpegPath, ffmpegErr + ffmpegPath = truePath + ffmpegErr = nil + defer func() { + ffmpegPath, ffmpegErr = origPath, origErr + }() + + ff := &ffmpeg{} + _, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75) + Expect(err).To(MatchError(ErrAnimatedWebPUnsupported)) + }) + }) }) From e6680c904b52cd8481a8f29530816ed07728ebb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 12:20:27 -0400 Subject: [PATCH 12/28] fix(playlists): allow toggling auto-import and avoid unnecessary artwork reloads (#5421) * fix(playlists): allow toggling auto-import (sync) via REST API The updatePlaylistEntity handler was not applying the sync field from incoming requests, causing the auto-import toggle in the UI to have no effect. Apply the sync value for file-backed playlists only. * fix(playlists): enhance update logic for playlist metadata and sync toggle Signed-off-by: Deluan * fix(playlists): address code review feedback - Add pointer equality short-circuit in rulesEqual before reflect.DeepEqual - Guard against empty ID in Put's partial-update path - Only apply Sync when it actually differs from current value, preventing zero-value overwrites from partial payloads * fix(playlists): remove unused parameters from Update method Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/playlists/rest_adapter.go | 54 ++++++++++++++++++---- core/playlists/rest_adapter_test.go | 70 +++++++++++++++++++++++++++++ model/playlist.go | 2 +- persistence/playlist_repository.go | 9 +++- tests/mock_playlist_repo.go | 2 +- 5 files changed, 125 insertions(+), 12 deletions(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3fecda0d5..c9b7c4ea6 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -3,9 +3,11 @@ package playlists import ( "context" "errors" + "reflect" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" ) @@ -32,8 +34,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) +func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -77,7 +79,7 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -93,11 +95,45 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { return rest.ErrPermissionDenied } - // Apply ownership change (admin only) - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID + + contentChanged := entity.Name != current.Name || + entity.Comment != current.Comment || + (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || + !rulesEqual(current.Rules, entity.Rules) + + if contentChanged { + if entity.OwnerID != "" { + current.OwnerID = entity.OwnerID + } + current.Rules = entity.Rules + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + } + return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - // Apply smart playlist rules update - current.Rules = entity.Rules - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) + + // Only sync/public changed — skip updatedAt so cover art URLs stay stable + var cols []string + if current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + cols = append(cols, "sync") + } + if current.Public != entity.Public { + current.Public = entity.Public + cols = append(cols, "public") + } + if len(cols) == 0 { + return nil + } + return s.ds.Playlist(ctx).Put(current, cols...) +} + +func rulesEqual(a, b *criteria.Criteria) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return reflect.DeepEqual(a, b) } diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 097bc6310..90d22327a 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -142,6 +142,76 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) + It("allows toggling sync for file-backed playlists", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["file-pls"] = &model.Playlist{ + ID: "file-pls", + Name: "File Playlist", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "File Playlist", Sync: false} + err := repo.Update("file-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("does not allow setting sync on non-file-backed playlists", func() { + mockPlsRepo.Data["manual-pls"] = &model.Playlist{ + ID: "manual-pls", + Name: "Manual Playlist", + OwnerID: "user-1", + Path: "", + Sync: false, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Manual Playlist", Sync: true} + err := repo.Update("manual-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) + }) + + It("does not bump updatedAt when only public changes", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["pls-pub"] = &model.Playlist{ + ID: "pls-pub", + Name: "My Playlist", + OwnerID: "user-1", + Public: false, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "My Playlist", Public: true} + err := repo.Update("pls-pub", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("bumps updatedAt when name changes along with sync", func() { + mockPlsRepo.Data["file-pls2"] = &model.Playlist{ + ID: "file-pls2", + Name: "Old Name", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "New Name", Sync: false} + err := repo.Update("file-pls2", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("New Name")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) diff --git a/model/playlist.go b/model/playlist.go index e2f93993d..dc549f039 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -123,7 +123,7 @@ type PlaylistRepository interface { ResourceRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) - Put(pls *Playlist) error + Put(pls *Playlist, cols ...string) error Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9bbc41c5c..4152505d2 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -97,8 +97,15 @@ func (r *playlistRepository) Delete(id string) error { return r.delete(And{Eq{"id": id}, r.userFilter()}) } -func (r *playlistRepository) Put(p *model.Playlist) error { +func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { pls := dbPlaylist{Playlist: *p} + if len(cols) > 0 { + if pls.ID == "" { + return errors.New("playlist id is required for partial update") + } + _, err := r.put(pls.ID, pls, cols...) + return err + } if pls.ID == "" { pls.CreatedAt = time.Now() } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9bdc52152..9b38ea5b5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -45,7 +45,7 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { +func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } From 259c1a9484482fcc13d77812089fb3be454a5202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 17:50:09 -0400 Subject: [PATCH 13/28] feat(subsonic): add sonicSimilarity extension as plugin capability (#5419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): add sonicSimilarity capability types Defines the SonicSimilarity plugin capability interface with GetSonicSimilarTracks and FindSonicPath methods, along with their request/response types. * feat(sonic): add core sonic similarity service Implements the Sonic service with HasProvider, GetSonicSimilarTracks, and FindSonicPath, delegating to the PluginLoader and using the Matcher for index-preserving library resolution. * test(sonic): add sonic service unit tests Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with mock plugin loader and provider, verifying error propagation and successful match resolution via the library matcher. * feat(matcher): add MatchSongsToLibraryMap for index-preserving matching Adds a new method alongside MatchSongsToLibrary that returns a map[int]MediaFile keyed by input song index rather than a flat slice, enabling callers to correlate similarity scores back to the original position in the results. * fix(sonic): check provider availability before MediaFile lookup Avoids unnecessary DB call when no plugin is available, and ensures the correct error path is tested. * feat(plugins): add sonic similarity adapter Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the plugin system to the core sonic service via Extism plugin functions. Reuses existing songRefsToAgentSongs helper for SongRef conversion. * feat(plugins): add LoadSonicSimilarity to plugin manager Adds Manager.LoadSonicSimilarity method following the pattern of LoadLyricsProvider, enabling the core sonic service to load a SonicSimilarityAdapter from a named plugin. * feat(subsonic): add sonicMatch response type Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results. * feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types. * feat(subsonic): advertise sonicSimilarity extension when plugin available Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter. * feat(subsonic): wire sonic similarity service into router Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go. * fix(e2e): add sonic parameter to subsonic.New call in e2e tests * test(subsonic): add sonicSimilarity extension advertisement tests Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin. * feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints Handlers return ErrorDataNotFound when no sonic service is configured, preventing nil panics. E2e tests verify both endpoints return proper error responses when no plugin is available. * fix(subsonic): return HTTP 404 when no sonic similarity plugin available Endpoints are always registered but return 404 when no provider is available, rather than a subsonic error code 70. * refactor: clean up sonic similarity code after review Extract shared helpers to reduce duplication across the sonic similarity implementation: loadAllMatches in matcher consolidates the 4-phase matching pipeline, songRefToAgentSong eliminates per-iteration slice allocation in the adapter, sonicMatchResponse deduplicates response building in handlers, and a package-level constant replaces raw capability name strings in core/sonic. * fix empty response shapes Signed-off-by: Deluan * test(plugins): add testdata plugin and e2e tests for sonic similarity Add a test-sonic-similarity WASM plugin that implements both GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity PDK. The plugin returns deterministic test data with decreasing similarity scores and supports error injection via config. Adapter tests verify the full round-trip through the WASM plugin including error handling. Also includes regenerated PDK code from make gen. * docs: update README to include new capabilities and usage examples for plugins Signed-off-by: Deluan * test(e2e): enhance sonic similarity tests with additional scenarios and mock provider Signed-off-by: Deluan * fix: address PR review feedback for sonic similarity Fix incorrect field names in README documentation ({from, to} → {startSong, endSong}) and remove unnecessary XML serialization test from e2e suite since OpenSubsonic endpoints only use JSON. * refactor: rename Matcher methods for conciseness Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to MatchSongsIndexed. The Matcher receiver already establishes the "to library" context, making that suffix redundant, and "Indexed" better describes the intent (preserving input ordering) than "Map" which describes the data structure. * refactor: standardize variable naming for media files in sonic path methods Signed-off-by: Deluan * refactor: simplify plugin loading by introducing adapter constructors Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/wire_gen.go | 6 +- cmd/wire_injectors.go | 3 + core/external/provider.go | 4 +- core/matcher/matcher.go | 69 +- core/matcher/matcher_test.go | 121 +++- core/sonic/sonic.go | 130 ++++ core/sonic/sonic_suite_test.go | 17 + core/sonic/sonic_test.go | 146 ++++ plugins/README.md | 651 +++++++++--------- plugins/capabilities/lyrics.yaml | 6 + plugins/capabilities/scrobbler.yaml | 6 + plugins/capabilities/sonic_similarity.go | 32 + plugins/capabilities/sonic_similarity.yaml | 92 +++ plugins/lyrics_adapter.go | 4 + plugins/manager.go | 64 +- plugins/metadata_agent.go | 36 +- plugins/pdk/go/lyrics/lyrics.go | 3 + plugins/pdk/go/lyrics/lyrics_stub.go | 3 + plugins/pdk/go/scrobbler/scrobbler.go | 3 + plugins/pdk/go/scrobbler/scrobbler_stub.go | 3 + .../pdk/go/sonicsimilarity/sonicsimilarity.go | 136 ++++ .../sonicsimilarity/sonicsimilarity_stub.go | 71 ++ .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../rust/nd-pdk-capabilities/src/lyrics.rs | 4 + .../rust/nd-pdk-capabilities/src/scrobbler.rs | 4 + .../src/sonicsimilarity.rs | 141 ++++ plugins/scrobbler_adapter.go | 14 + plugins/sonic_similarity_adapter.go | 92 +++ plugins/sonic_similarity_adapter_test.go | 110 +++ plugins/testdata/test-sonic-similarity/go.mod | 16 + plugins/testdata/test-sonic-similarity/go.sum | 14 + .../testdata/test-sonic-similarity/main.go | 71 ++ .../test-sonic-similarity/manifest.json | 7 + server/e2e/e2e_suite_test.go | 1 + server/e2e/subsonic_sonic_similarity_test.go | 272 ++++++++ server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 6 + server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/opensubsonic.go | 8 +- server/subsonic/opensubsonic_test.go | 87 ++- server/subsonic/playlists_test.go | 4 +- ... SonicMatches with data should match .JSON | 25 + ...s SonicMatches with data should match .XML | 10 + ...nicMatches without data should match .JSON | 8 + ...onicMatches without data should match .XML | 1 + server/subsonic/responses/responses.go | 6 + server/subsonic/responses/responses_test.go | 29 + server/subsonic/searching_test.go | 2 +- server/subsonic/sonic_similarity.go | 69 ++ server/subsonic/transcode_test.go | 6 +- 51 files changed, 2159 insertions(+), 461 deletions(-) create mode 100644 core/sonic/sonic.go create mode 100644 core/sonic/sonic_suite_test.go create mode 100644 core/sonic/sonic_test.go create mode 100644 plugins/capabilities/sonic_similarity.go create mode 100644 plugins/capabilities/sonic_similarity.yaml create mode 100644 plugins/pdk/go/sonicsimilarity/sonicsimilarity.go create mode 100644 plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs create mode 100644 plugins/sonic_similarity_adapter.go create mode 100644 plugins/sonic_similarity_adapter_test.go create mode 100644 plugins/testdata/test-sonic-similarity/go.mod create mode 100644 plugins/testdata/test-sonic-similarity/go.sum create mode 100644 plugins/testdata/test-sonic-similarity/main.go create mode 100644 plugins/testdata/test-sonic-similarity/manifest.json create mode 100644 server/e2e/subsonic_sonic_similarity_test.go create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML create mode 100644 server/subsonic/sonic_similarity.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f66df2e75..0939eef4d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" @@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } @@ -219,7 +221,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index d87a8d6d3..bb5c5b5f3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -43,9 +44,11 @@ var allProviders = wire.NewSet( metrics.GetPrometheusInstance, db.Db, plugins.GetManager, + sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/core/external/provider.go b/core/external/provider.go index 7e8aaba1c..4f3295cc7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode } if err == nil && len(songs) > 0 { - return e.matcher.MatchSongsToLibrary(ctx, songs, count) + return e.matcher.MatchSongs(ctx, songs, count) } // Fallback to existing similar artists + top songs algorithm @@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT } } - mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count) + mfs, err := e.matcher.MatchSongs(ctx, songs, count) if err != nil { return nil, err } diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index cf6c99e28..54c2a368e 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// MatchSongs matches agent song results to local library tracks using a multi-phase // matching algorithm that prioritizes accuracy over recall. // // # Algorithm Overview @@ -107,25 +107,58 @@ func New(ds model.DataStore) *Matcher { // // Returns up to 'count' MediaFiles from the library that best match the input songs, // preserving the original order from the agent. Songs that cannot be matched are skipped. -func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - idMatches, err := m.loadTracksByID(ctx, songs) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by title: %w", err) +func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + if len(songs) == 0 { + return nil, nil } - return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil +} + +// MatchSongsIndexed matches agent song results to local library tracks and returns a map +// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. +// This preserves original indices, allowing callers to correlate results back to the input slice. +func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { + if len(songs) == 0 { + return nil, nil + } + + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + + result := make(map[int]model.MediaFile, len(songs)) + for i, t := range songs { + if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { + result[i] = mf + } + } + return result, nil +} + +func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { + byID, err = m.loadTracksByID(ctx, songs) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) + } + byMBID, err = m.loadTracksByMBID(ctx, songs, byID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) + } + byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + return byID, byMBID, byISRC, byTitle, nil } // songMatchedIn checks if a song has already been matched in any of the provided match maps. diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 8996cf71d..42e3ec88d 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -75,7 +75,7 @@ var _ = Describe("Matcher", func() { Return(artistTracks, nil).Maybe() } - Describe("MatchSongsToLibrary", func() { + Describe("MatchSongs", func() { Context("matching by direct ID", func() { It("matches songs with an ID field to MediaFiles by ID", func() { conf.Server.Matcher.FuzzyThreshold = 100 @@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() { } expectIDPhase(model.MediaFiles{idMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-1")) @@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() { } expectMBIDPhase(model.MediaFiles{mbidMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-mbid")) @@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() { } expectISRCPhase(model.MediaFiles{isrcMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-isrc")) @@ -142,7 +142,7 @@ var _ = Describe("Matcher", func() { ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", } setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-title")) @@ -157,7 +157,7 @@ var _ = Describe("Matcher", func() { ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-fuzzy")) @@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() { {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, } setupTitleOnlyExpectations(differentTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) }) @@ -189,7 +189,7 @@ var _ = Describe("Matcher", func() { ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("br-live")) @@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() { ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", } setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) Expect(result[0].ID).To(Equal("br")) @@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() { } expectIDPhase(model.MediaFiles{idMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-id")) @@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() { {ID: "c", Title: "Song C", Artist: "Artist"}, } setupTitleOnlyExpectations(tracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 2) + result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) }) @@ -256,13 +256,60 @@ var _ = Describe("Matcher", func() { Context("empty input", func() { It("returns empty results for no songs", func() { - result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5) + result, err := m.MatchSongs(ctx, []agents.Song{}, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) }) }) }) + Describe("MatchSongsIndexed", func() { + It("returns index-keyed map of matched songs", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Song One", Artist: "Artist A"}, + {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, + {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} + mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} + + expectIDPhase(model.MediaFiles{mf1, mf2}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-1")) + Expect(result[1].ID).To(Equal("track-2")) + _, exists := result[2] + Expect(exists).To(BeFalse()) + }) + + It("preserves original indices when some songs don't match", func() { + songs := []agents.Song{ + {Name: "Unknown Song", Artist: "Unknown Artist"}, + {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} + + expectIDPhase(model.MediaFiles{mf1}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + _, exists := result[0] + Expect(exists).To(BeFalse()) + Expect(result[1].ID).To(Equal("track-1")) + }) + + It("returns empty map for empty input", func() { + result, err := m.MatchSongsIndexed(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + Describe("specificity level matching", func() { BeforeEach(func() { conf.Server.Matcher.FuzzyThreshold = 100 @@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(3)) @@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -495,7 +542,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -515,7 +562,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -535,7 +582,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -556,7 +603,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -577,7 +624,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -603,7 +650,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -620,7 +667,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -640,7 +687,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -657,7 +704,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -677,7 +724,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -694,7 +741,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -711,7 +758,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -737,7 +784,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -757,7 +804,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(3)) @@ -778,7 +825,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) - result, err := m.MatchSongsToLibrary(ctx, songs, 2) + result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go new file mode 100644 index 000000000..19eb69c65 --- /dev/null +++ b/core/sonic/sonic.go @@ -0,0 +1,130 @@ +package sonic + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const capabilitySonicSimilarity = "SonicSimilarity" + +type SimilarResult struct { + Song agents.Song + Similarity float64 +} + +type SimilarMatch struct { + MediaFile model.MediaFile + Similarity float64 +} + +type Provider interface { + GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error) + FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error) +} + +type PluginLoader interface { + PluginNames(capability string) []string + LoadSonicSimilarity(name string) (Provider, bool) +} + +type Sonic struct { + ds model.DataStore + pluginLoader PluginLoader + matcher *matcher.Matcher +} + +func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic { + return &Sonic{ + ds: ds, + pluginLoader: pluginLoader, + matcher: matcher, + } +} + +func (s *Sonic) HasProvider() bool { + return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 +} + +func (s *Sonic) loadProvider() (Provider, error) { + names := s.pluginLoader.PluginNames(capabilitySonicSimilarity) + if len(names) == 0 { + return nil, model.ErrNotFound + } + provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0]) + if !ok { + return nil, model.ErrNotFound + } + return provider, nil +} + +func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) { + songs := make([]agents.Song, len(results)) + for i, r := range results { + songs[i] = r.Song + } + + matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs) + if err != nil { + return nil, fmt.Errorf("matching songs to library: %w", err) + } + + var matches []SimilarMatch + for i, r := range results { + if mf, ok := matchMap[i]; ok { + matches = append(matches, SimilarMatch{ + MediaFile: mf, + Similarity: r.Similarity, + }) + } + } + return matches, nil +} + +func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + mf, err := s.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("getting media file %s: %w", id, err) + } + + results, err := provider.GetSonicSimilarTracks(ctx, mf, count) + if err != nil { + log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} + +func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + startMF, err := s.ds.MediaFile(ctx).Get(startID) + if err != nil { + return nil, fmt.Errorf("getting start media file %s: %w", startID, err) + } + endMF, err := s.ds.MediaFile(ctx).Get(endID) + if err != nil { + return nil, fmt.Errorf("getting end media file %s: %w", endID, err) + } + + results, err := provider.FindSonicPath(ctx, startMF, endMF, count) + if err != nil { + log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} diff --git a/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go new file mode 100644 index 000000000..7058e5de9 --- /dev/null +++ b/core/sonic/sonic_suite_test.go @@ -0,0 +1,17 @@ +package sonic_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSonic(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Sonic Suite") +} diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go new file mode 100644 index 000000000..81739b726 --- /dev/null +++ b/core/sonic/sonic_test.go @@ -0,0 +1,146 @@ +package sonic_test + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockPluginLoader struct { + names []string + provider sonic.Provider + loadOk bool +} + +func (m *mockPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return m.provider, m.loadOk +} + +type mockProvider struct { + similarResults []sonic.SimilarResult + similarErr error + pathResults []sonic.SimilarResult + pathErr error +} + +func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.similarResults, m.similarErr +} + +func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.pathResults, m.pathErr +} + +var _ = Describe("Sonic", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + loader *mockPluginLoader + service *sonic.Sonic + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{} + loader = &mockPluginLoader{} + }) + + Describe("HasProvider", func() { + It("returns false when no plugins available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeFalse()) + }) + + It("returns true when a plugin is available", func() { + loader.names = []string{"test-plugin"} + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeTrue()) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when media file not found", func() { + loader.names = []string{"test-plugin"} + loader.provider = &mockProvider{} + loader.loadOk = true + ds.MockedMediaFile = &tests.MockMediaFileRepo{} + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10) + Expect(err).To(HaveOccurred()) + }) + + It("returns matched results from plugin", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{ + similarResults: []sonic.SimilarResult{ + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + }, + } + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).ToNot(HaveOccurred()) + Expect(matches).To(HaveLen(1)) + Expect(matches[0].MediaFile.ID).To(Equal("song-2")) + Expect(matches[0].Similarity).To(Equal(0.85)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when plugin call fails", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{pathErr: errors.New("plugin error")} + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/plugins/README.md b/plugins/README.md index e37a94d7a..048cf549d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,8 +1,8 @@ # Navidrome Plugin System -Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access. +Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access. -The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). +The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). **Essential Extism Resources:** - [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture @@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Capabilities](#capabilities) - [MetadataAgent](#metadataagent) - [Scrobbler](#scrobbler) + - [Lyrics](#lyrics) + - [SonicSimilarity](#sonicsimilarity) + - [TaskWorker](#taskworker) - [Lifecycle](#lifecycle) + - [SchedulerCallback](#schedulercallback) + - [WebSocketCallback](#websocketcallback) - [Host Services](#host-services) - - [HTTP Requests](#http-requests) + - [HTTP](#http) - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) + - [Task](#task) - [WebSocket](#websocket) - [Library](#library) - [Artwork](#artwork) @@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing: 1. **`manifest.json`** – Plugin metadata (name, author, version, permissions) 2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions -### Plugin Package Structure - -``` -my-plugin.ndp (zip archive) -├── manifest.json # Required: Plugin metadata -└── plugin.wasm # Required: Compiled WebAssembly module -``` - ### Plugin Naming Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field: @@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example: "version": "1.0.0", "description": "What this plugin does", "website": "https://example.com", + "config": { + "schema": { ... }, + "uiSchema": { ... } + }, "permissions": { "http": { "reason": "Fetch metadata from external API", @@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` +**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` + +#### Config Definition + +The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI: + +```json +{ + "config": { + "schema": { + "type": "object", + "properties": { + "api_key": { "type": "string", "title": "API Key" }, + "max_retries": { "type": "integer", "default": 3 } + }, + "required": ["api_key"] + }, + "uiSchema": { + "api_key": { "ui:widget": "password" } + } + } +} +``` + #### Experimental Features Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: @@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ```json { - "name": "Threaded Plugin", - "author": "Author Name", - "version": "1.0.0", "experimental": { "threads": { "reason": "Required for concurrent audio processing" @@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ## Capabilities -Capabilities define what your plugin can do. They're automatically detected based on which functions you export. +Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities. ### MetadataAgent -Provides artist and album metadata. Export one or more of these functions: +Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. -| Function | Input | Output | Description | -|---------------------------|----------------------------|----------------------------------|----------------------| -| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | -| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | -| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | -| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | -| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | -| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | -| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | -| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | - -**Example:** - -```go -type ArtistInput struct { - ID string `json:"id"` - Name string `json:"name"` - MBID string `json:"mbid,omitempty"` -} - -type BiographyOutput struct { - Biography string `json:"biography"` -} - -//go:wasmexport nd_get_artist_biography -func ndGetArtistBiography() int32 { - var input ArtistInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return 1 - } - - // Fetch biography from your data source... - output := BiographyOutput{Biography: "Artist biography..."} - pdk.OutputJSON(output) - return 0 -} -``` +| Function | Input | Output | Description | +|-----------------------------------|----------------------------|----------------------------------|--------------------------| +| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | +| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | +| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | +| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | +| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | +| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | +| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | +| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | +| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track | +| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album | +| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist | To use the plugin as a metadata agent, add it to your config: @@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config: Agents = "lastfm,spotify,my-plugin" ``` +**Example (using Go PDK package):** + +```go +package main + +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" + +type myPlugin struct{} + +func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { + return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil +} + +func init() { metadata.Register(&myPlugin{}) } +func main() {} +``` + +**Example (raw wasmexport):** + +```go +//go:wasmexport nd_get_artist_biography +func ndGetArtistBiography() int32 { + var input ArtistInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return 1 + } + pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."}) + return 0 +} +``` + ### Scrobbler -Integrates with external scrobbling services. Export one or more of these functions: +Integrates with external scrobbling services. All three methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|----------------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|------------------------------|-----------------------|--------|-----------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | -> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes. +> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. **Manifest permission:** @@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error ```go import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" -// Return error using predefined constants return scrobbler.ScrobblerErrorNotAuthorized return scrobbler.ScrobblerErrorRetryLater return scrobbler.ScrobblerErrorUnrecoverable ``` +### Lyrics + +Provides lyrics for tracks. The single method is **required**. + +| Function | Input | Output | Description | +|-------------------------|-------------------------------|------------------------------------|-----------------| +| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics | + +Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages. + +### SonicSimilarity + +Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**. + +| Function | Input | Output | Description | +|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------| +| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks | +| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs | + +Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0). + +### TaskWorker + +Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. + +| Function | Input | Output | Description | +|---------------------|---------------------------------------------|---------|----------------------| +| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task| + +The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success. + ### Lifecycle -Optional initialization callback. Export this function to run code when your plugin loads: +Optional initialization callback. Called once after the plugin fully loads. | Function | Input | Output | Description | |--------------|-------|------------|--------------------------------| | `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads | -Useful for initializing connections, scheduling recurring tasks, etc. +Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading. + +### SchedulerCallback + +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. + +| Function | Input | Output | Description | +|---------------------------|----------------------------------------------|--------|-----------------------------| +| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event | + +### WebSocketCallback + +Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service. + +| Function | Input | Description | +|----------------------------------|---------------------------------|----------------------------------| +| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | +| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | +| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | +| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | --- ## Host Services -Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest. +Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest. -### HTTP Requests +### Go PDK Setup -Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests. +All host service examples below use the generated Go SDK. Add this to your `go.mod`: + +``` +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go +``` + +Then import: + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/host" +``` + +### HTTP + +Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. **Manifest permission:** @@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis } ``` +**Host functions:** + +| Function | Parameters | Returns | +|-------------|----------------------------------------------------------|----------------------------------| +| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | + **Usage:** ```go -req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data") -req.SetHeader("Authorization", "Bearer " + apiKey) -resp := req.Send() - -if resp.Status() == 200 { - data := resp.Body() - // Process response... +resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: "https://api.example.com/data", + Headers: map[string]string{"Authorization": "Bearer " + apiKey}, +}) +if resp.StatusCode == 200 { + // Process resp.Body } ``` ### Scheduler -Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events. +Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events. **Manifest permission:** @@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call | `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback | | `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task | -**Callback function:** +**Usage:** ```go -type SchedulerCallbackInput struct { - ScheduleID string `json:"scheduleId"` - Payload string `json:"payload"` - IsRecurring bool `json:"isRecurring"` -} - -//go:wasmexport nd_scheduler_callback -func ndSchedulerCallback() int32 { - var input SchedulerCallbackInput - pdk.InputJSON(&input) - - // Handle the scheduled task based on payload - pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID) - return 0 -} -``` - -**Scheduling tasks (using generated SDK):** - -Add the generated SDK to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -Then import and use: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Schedule one-time task in 60 seconds scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "") @@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID) ### Cache -Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace. +In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart. **Manifest permission:** @@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own **TTL:** Pass `0` for the default (24 hours), or specify seconds. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Cache a value for 1 hour host.CacheSetString("api-response", responseData, 3600) -// Retrieve (check Exists before using Value) -result, err := host.CacheGetString("api-response") -if result.Exists { - data := result.Value +// Retrieve (returns value, exists, error) +value, exists, err := host.CacheGetString("api-response") +if exists { + // Use value } ``` -> **Note:** Cache is in-memory only and cleared on server restart. - ### KVStore -Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database. +Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`. **Manifest permission:** @@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its } ``` -**Permission options:** - `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB +**Key constraints:** Maximum 256 bytes, must be valid UTF-8. + **Host functions:** -| Function | Parameters | Description | -|--------------------------|--------------|-----------------------------------| -| `kvstore_set` | `key, value` | Store a byte value | -| `kvstore_get` | `key` | Retrieve a byte value | -| `kvstore_delete` | `key` | Delete a value | -| `kvstore_has` | `key` | Check if key exists | -| `kvstore_list` | `prefix` | List keys matching prefix | -| `kvstore_getstorageused` | - | Get current storage usage (bytes) | +| Function | Parameters | Description | +|-----------------------------|--------------------------|-----------------------------------| +| `kvstore_set` | `key, value` | Store a byte value | +| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration | +| `kvstore_get` | `key` | Retrieve a byte value | +| `kvstore_getmany` | `keys` | Retrieve multiple values at once | +| `kvstore_has` | `key` | Check if key exists | +| `kvstore_list` | `prefix` | List keys matching prefix | +| `kvstore_delete` | `key` | Delete a value | +| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix | +| `kvstore_getstorageused` | – | Get current storage usage (bytes) | -**Key constraints:** -- Maximum key length: 256 bytes -- Keys must be valid UTF-8 strings - -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Store a value (as raw bytes) token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`) -_, err := host.KVStoreSet("oauth:spotify", token) +host.KVStoreSet("oauth:spotify", token) + +// Store with TTL (auto-expires after 1 hour) +host.KVStoreSetWithTTL("session:abc", sessionData, 3600) // Retrieve a value -result, err := host.KVStoreGet("oauth:spotify") -if result.Exists { +value, exists, err := host.KVStoreGet("oauth:spotify") +if exists { var tokenData map[string]string - json.Unmarshal(result.Value, &tokenData) + json.Unmarshal(value, &tokenData) } -// List all keys with prefix -keysResult, err := host.KVStoreList("user:") -for _, key := range keysResult.Keys { - // Process each key -} +// Batch retrieve +results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"}) + +// List and delete by prefix +keys, err := host.KVStoreList("user:") +host.KVStoreDeleteByPrefix("user:") // Check storage usage -usageResult, err := host.KVStoreGetStorageUsed() -fmt.Printf("Using %d bytes\n", usageResult.Bytes) - -// Delete a value -host.KVStoreDelete("oauth:spotify") +usage, err := host.KVStoreGetStorageUsed() +fmt.Printf("Using %d bytes\n", usage) ``` -> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`. +### Task + +Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. + +**Manifest permission:** + +```json +{ + "permissions": { + "taskqueue": { + "reason": "Process audio analysis in the background", + "maxConcurrency": 2 + } + } +} +``` + +**Host functions:** + +| Function | Parameters | Description | +|---------------------|---------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +**Usage:** + +```go +// Create a queue with retry configuration +host.TaskCreateQueue("analysis", host.QueueConfig{ + Concurrency: 2, + MaxRetries: 3, + BackoffMs: 1000, +}) + +// Enqueue a task +taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`)) + +// Check task status +info, err := host.TaskGet(taskID) +fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt) +``` ### WebSocket -Establish persistent WebSocket connections to external services. +Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events. **Manifest permission:** @@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services. **Host functions:** -| Function | Parameters | Description | -|------------------------|---------------------------------|-------------------| -| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | -| `websocket_sendtext` | `connectionId, message` | Send text message | -| `websocket_sendbinary` | `connectionId, data` | Send binary data | -| `websocket_close` | `connectionId, code?, reason?` | Close connection | +| Function | Parameters | Description | +|----------------------------|---------------------------------|-------------------| +| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | +| `websocket_sendtext` | `connectionId, message` | Send text message | +| `websocket_sendbinary` | `connectionId, data` | Send binary data | +| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection | -**Callback functions (export these to receive events):** +**Usage:** -| Function | Input | Description | -|----------------------------------|---------------------------------|----------------------------------| -| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | -| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | -| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | -| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +```go +connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "") +host.WebSocketSendText(connID, `{"op": 1, "d": null}`) +host.WebSocketCloseConnection(connID, 1000, "done") +``` ### Library @@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via ```go import "os" -// Read a file from library 1 content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") - -// List directory contents entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem. +> **Security:** Filesystem access is read-only and restricted to configured library paths only. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK: +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Get a specific library -resp, err := host.LibraryGetLibrary(1) -if err != nil { - // Handle error -} -library := resp.Result +library, err := host.LibraryGetLibrary(1) +fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs) // Get all libraries -resp, err := host.LibraryGetAllLibraries() -for _, lib := range resp.Result { - // lib is of type host.Library +libraries, err := host.LibraryGetAllLibraries() +for _, lib := range libraries { fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs) } ``` @@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). | `artwork_gettrackurl` | `id, size` | Artwork URL | | `artwork_getplaylisturl` | `id, size` | Artwork URL | +**Usage:** + +```go +url, err := host.ArtworkGetAlbumUrl("album-id", 300) +``` + ### SubsonicAPI Call Navidrome's Subsonic API internally (no network round-trip). @@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip). } ``` -> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API. +> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. -**Host function:** +**Host functions:** -| Function | Parameters | Returns | -|--------------------|------------|---------------| -| `subsonicapi_call` | `uri` | JSON response | +| Function | Parameters | Returns | +|-----------------------|------------|--------------------------------| +| `subsonicapi_call` | `uri` | JSON response string | +| `subsonicapi_callraw` | `uri` | Content type + binary response | **Usage:** ```go -// The URI must include the 'u' parameter with the username -response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") +// JSON response +response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") + +// Binary response (e.g., cover art, streams) +contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username") ``` ### Config -Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings). +Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration. > **Note:** This service is always available and does not require a manifest permission. @@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh | `config_getint` | `key` | `value, exists` | | `config_keys` | `prefix` | Array of matching key names | -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get a string configuration value +// Get a configuration value value, exists := host.ConfigGet("api_key") -if exists { - // Use the value -} // Get an integer configuration value count, exists := host.ConfigGetInt("max_retries") // List all keys with a prefix (useful for user-specific config) keys := host.ConfigKeys("user:") -for _, key := range keys { - // key might be "user:john", "user:jane", etc. -} // List all configuration keys allKeys := host.ConfigKeys("") @@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("") ### Users -Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information. +Access user information for the users that the plugin has been granted access to. **Manifest permission:** @@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to } ``` -**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways: +**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access: 1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings 2. **Select specific users** – Choose individual users from the user list @@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled. | Function | Parameters | Returns | |------------------|------------|-----------------------| | `users_getusers` | – | Array of User objects | +| `users_getadmins`| – | Array of admin Users | **User object fields:** @@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled. > **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins. -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get all users the plugin has access to users, err := host.UsersGetUsers() -if err != nil { - pdk.Log(pdk.LogError, "Failed to get users: " + err.Error()) - return -} - for _, user := range users { pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")") - if user.IsAdmin { - pdk.Log(pdk.LogInfo, " - Administrator") - } } -``` -**Rust example:** - -```rust -use nd_pdk_host::users::get_users; - -let users = get_users()?; -for user in users { - println!("User: {} ({})", user.user_name, user.name); -} -``` - -**Python example:** - -```python -from host.nd_host_users import users_get_users - -users = users_get_users() -for user in users: - print(f"User: {user['userName']} ({user['name']})") +admins, err := host.UsersGetAdmins() ``` --- @@ -834,20 +898,20 @@ if !ok { } ``` +For more advanced access (listing keys, integer values), use the [Config](#config) host service. + --- ## Building Plugins ### Supported Languages -Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details. +Plugins can be written in any language that Extism supports. We recommend: -We recommend: - -- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk) -- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk) -- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk) -- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk) +- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support. +- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk). +- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages. +- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk). ### Go with TinyGo (Recommended) @@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm #### Using Go PDK Packages -Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: +Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: ```go package main -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" -) +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" type myPlugin struct{} @@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil } -func init() { - metadata.Register(&myPlugin{}) -} - +func init() { metadata.Register(&myPlugin{}) } func main() {} ``` @@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0 replace github.com/navidrome/navidrome => ../../.. ``` -Available capability packages: +**Available capability packages:** -| Package | Import Path | Description | -|-------------|----------------------------|--------------------------------------| -| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | -| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | -| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | -| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | -| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | -| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) | +| Package | Import Path | Description | +|-------------------|--------------------------------------|--------------------------------------| +| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | +| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | +| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers | +| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery | +| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing | +| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | +| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | +| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | +| `host` | `plugins/pdk/go/host` | Host service SDK (all services) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm #### Using Rust PDK -The Rust PDK provides generated type-safe wrappers for both capabilities and host services: - ```toml # Cargo.toml [dependencies] @@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports ```rust use nd_pdk::host::{cache, scheduler, library}; -// Cache a value for 1 hour cache::set_string("my_key", "my_value", 3600)?; - -// Schedule a recurring task scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; - -// Access library metadata let libs = library::get_all_libraries()?; ``` -See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples. +See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation. ### Python (with extism-py) @@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` +**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). + ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: @@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples. -### Using Host Service SDKs - -Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`. - -**For Go plugins:** Import the SDK as a Go module: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" -``` - -Add to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -See [pdk/go/README.md](pdk/go/README.md) for detailed documentation. - -**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations). - -**Recommendations:** - -- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem. -- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety. -- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages. - --- ## Examples See [examples/](examples/) for complete working plugins: -| Plugin | Language | Capabilities | Host Services | Description | -|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------| -| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | -| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | -| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) | +| Plugin | Language | Capabilities | Host Services | Description | +|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------| +| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | +| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | +| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | +| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | --- - ## Security Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only +2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features. +6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed - --- ## Runtime Management @@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive. -If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes. +If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes. ### Enabling/Disabling Plugins @@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis - **In-flight requests** – When reloading, existing requests complete before the new version takes over - **Config changes** – Changes to the plugin configuration in the UI are applied immediately -- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded +- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded \ No newline at end of file diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 4ac907559..04dd283dd 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -102,6 +102,12 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. path: type: string description: |- diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index f62da1745..8ada5f7e4 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -128,6 +128,12 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. path: type: string description: |- diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go new file mode 100644 index 000000000..aadb9396e --- /dev/null +++ b/plugins/capabilities/sonic_similarity.go @@ -0,0 +1,32 @@ +package capabilities + +// SonicSimilarity provides audio-similarity based track discovery. +// +//nd:capability name=sonicsimilarity required=true +type SonicSimilarity interface { + //nd:export name=nd_get_sonic_similar_tracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + + //nd:export name=nd_find_sonic_path + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml new file mode 100644 index 000000000..cba97d9b0 --- /dev/null +++ b/plugins/capabilities/sonic_similarity.yaml @@ -0,0 +1,92 @@ +version: v1-draft +exports: + nd_get_sonic_similar_tracks: + input: + $ref: '#/components/schemas/GetSonicSimilarTracksRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json + nd_find_sonic_path: + input: + $ref: '#/components/schemas/FindSonicPathRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json +components: + schemas: + FindSonicPathRequest: + properties: + startSong: + $ref: '#/components/schemas/SongRef' + endSong: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - startSong + - endSong + - count + GetSonicSimilarTracksRequest: + properties: + song: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - song + - count + SongRef: + description: SongRef is a reference to a song with metadata for matching. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: Artist is the artist name. + artistMbid: + type: string + description: ArtistMBID is the MusicBrainz artist ID. + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: Duration is the song duration in seconds. + required: + - name + SonicMatch: + properties: + song: + $ref: '#/components/schemas/SongRef' + similarity: + type: number + format: float + required: + - song + - similarity + SonicSimilarityResponse: + properties: + matches: + type: array + items: + $ref: '#/components/schemas/SonicMatch' + required: + - matches diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 43ebc0e4b..c66b027d7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -21,6 +21,10 @@ func init() { ) } +func newLyricsPlugin(p *plugin) *LyricsPlugin { + return &LyricsPlugin{name: p.name, plugin: p} +} + // LyricsPlugin adapts a WASM plugin with the Lyrics capability. type LyricsPlugin struct { name string diff --git a/plugins/manager.go b/plugins/manager.go index 0c7c91ed8..0e9419bfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" @@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string { return names } -// LoadMediaAgent loads and returns a media agent plugin by name. -// Returns false if the plugin is not found or doesn't have the MetadataAgent capability. func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) { - return nil, false - } - - // Create a new metadata agent adapter for this plugin - return &MetadataAgent{ - name: plugin.name, - plugin: plugin, - }, true + return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent) } -// LoadScrobbler loads and returns a scrobbler plugin by name. -// Returns false if the plugin is not found or doesn't have the Scrobbler capability. func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) { - return nil, false - } - - // Build user ID map for fast lookups - userIDMap := make(map[string]struct{}) - for _, id := range plugin.allowedUserIDs { - userIDMap[id] = struct{}{} - } - - // Create a new scrobbler adapter for this plugin with user authorization config - return &ScrobblerPlugin{ - name: plugin.name, - plugin: plugin, - allowedUserIDs: plugin.allowedUserIDs, - allUsers: plugin.allUsers, - userIDMap: userIDMap, - }, true + return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -// LoadLyricsProvider loads and returns a lyrics provider plugin by name. func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) +} + +func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin) +} + +func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) { m.mu.RLock() - plugin, ok := m.plugins[name] + p, ok := m.plugins[name] m.mu.RUnlock() - if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) { - return nil, false + var zero T + if !ok || !hasCapability(p.capabilities, cap) { + return zero, false } - - return &LyricsPlugin{ - name: plugin.name, - plugin: plugin, - }, true + return newAdapter(p), true } // PluginInfo contains basic information about a plugin for metrics/insights. diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 451ef26d1..52542300c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/utils/slice" ) // CapabilityMetadataAgent indicates the plugin can provide artist/album metadata. @@ -44,6 +45,10 @@ func init() { ) } +func newMetadataAgent(p *plugin) *MetadataAgent { + return &MetadataAgent{name: p.name, plugin: p} +} + // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } +// songRefToAgentSong converts a single SongRef to agents.Song +func songRefToAgentSong(s capabilities.SongRef) agents.Song { + return agents.Song{ + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artist: s.Artist, + ArtistMBID: s.ArtistMBID, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: uint32(s.Duration * 1000), + } +} + // songRefsToAgentSongs converts a slice of SongRef to agents.Song func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { - songs := make([]agents.Song, len(refs)) - for i, s := range refs { - songs[i] = agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), - } - } - return songs + return slice.Map(refs, songRefToAgentSong) } // Verify interface implementations at compile time diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 188371fee..6696bc4a1 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 91eec4997..fb3e3fb1a 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -65,6 +65,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index e16bfed4b..d27ae3a9c 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -92,6 +92,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 86a71af03..9e6f706ac 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -89,6 +89,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go new file mode 100644 index 000000000..7a2681f93 --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package sonicsimilarity + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} // Internal implementation holders +var ( + sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error) +) + +// Register registers a sonicsimilarity implementation. +// All methods are required. +func Register(impl SonicSimilarity) { + sonicSimilarTracksImpl = impl.GetSonicSimilarTracks + findSonicPathImpl = impl.FindSonicPath +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_get_sonic_similar_tracks +func _NdGetSonicSimilarTracks() int32 { + if sonicSimilarTracksImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetSonicSimilarTracksRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := sonicSimilarTracksImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_find_sonic_path +func _NdFindSonicPath() int32 { + if findSonicPathImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input FindSonicPathRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := findSonicPathImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go new file mode 100644 index 000000000..28d86301c --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -0,0 +1,71 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package sonicsimilarity + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ SonicSimilarity) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 85375b525..b0361a3a3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -10,5 +10,6 @@ pub mod lyrics; pub mod metadata; pub mod scheduler; pub mod scrobbler; +pub mod sonicsimilarity; pub mod taskworker; pub mod websocket; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index fcfe553f8..0a861a1ed 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -102,6 +102,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, /// Path is the full path to the track file, relative to the library root. /// Only included if the plugin has library permission with filesystem access for the track's library. #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index dd42e6803..348460374 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -122,6 +122,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, /// Path is the full path to the track file, relative to the library root. /// Only included if the plugin has library permission with filesystem access for the track's library. #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs new file mode 100644 index 000000000..eb2868929 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -0,0 +1,141 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// FindSonicPathRequest represents the FindSonicPathRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FindSonicPathRequest { + #[serde(default)] + pub start_song: SongRef, + #[serde(default)] + pub end_song: SongRef, + #[serde(default)] + pub count: i32, +} +/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSonicSimilarTracksRequest { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub count: i32, +} +/// SongRef is a reference to a song with metadata for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} +/// SonicMatch represents the SonicMatch data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicMatch { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub similarity: f64, +} +/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicSimilarityResponse { + #[serde(default)] + pub matches: Vec, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// SonicSimilarity requires all methods to be implemented. +/// SonicSimilarity provides audio-similarity based track discovery. +pub trait SonicSimilarity { + /// GetSonicSimilarTracks + fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result; + /// FindSonicPath + fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result; +} + +/// Register all exports for the SonicSimilarity capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_sonicsimilarity { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_sonic_similar_tracks( + req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + #[extism_pdk::plugin_fn] + pub fn nd_find_sonic_path( + req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 02c2b2889..302f5e1da 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -30,6 +30,20 @@ func init() { ) } +func newScrobblerPlugin(p *plugin) *ScrobblerPlugin { + userIDMap := make(map[string]struct{}) + for _, id := range p.allowedUserIDs { + userIDMap[id] = struct{}{} + } + return &ScrobblerPlugin{ + name: p.name, + plugin: p, + allowedUserIDs: p.allowedUserIDs, + allUsers: p.allUsers, + userIDMap: userIDMap, + } +} + // ScrobblerPlugin is an adapter that wraps an Extism plugin and implements // the scrobbler.Scrobbler interface for scrobbling to external services. type ScrobblerPlugin struct { diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go new file mode 100644 index 000000000..ff399680e --- /dev/null +++ b/plugins/sonic_similarity_adapter.go @@ -0,0 +1,92 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const CapabilitySonicSimilarity Capability = "SonicSimilarity" + +const ( + FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks" + FuncFindSonicPath = "nd_find_sonic_path" +) + +func init() { + registerCapability( + CapabilitySonicSimilarity, + FuncGetSonicSimilarTracks, + FuncFindSonicPath, + ) +} + +func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin { + return &SonicSimilarityPlugin{name: p.name, plugin: p} +} + +type SonicSimilarityPlugin struct { + name string + plugin *plugin +} + +func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.GetSonicSimilarTracksRequest{ + Song: mediaFileToSongRef(mf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncGetSonicSimilarTracks, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.FindSonicPathRequest{ + StartSong: mediaFileToSongRef(startMf), + EndSong: mediaFileToSongRef(endMf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncFindSonicPath, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { + ref := capabilities.SongRef{ + ID: mf.ID, + Name: mf.Title, + MBID: mf.MbzRecordingID, + Artist: mf.Artist, + ArtistMBID: mf.MbzArtistID, + Album: mf.Album, + AlbumMBID: mf.MbzAlbumID, + Duration: mf.Duration, + } + if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { + ref.ISRC = isrcs[0] + } + return ref +} + +func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult { + results := make([]sonic.SimilarResult, len(matches)) + for i, m := range matches { + results[i] = sonic.SimilarResult{ + Song: songRefToAgentSong(m.Song), + Similarity: m.Similarity, + } + } + return results +} + +var _ sonic.Provider = (*SonicSimilarityPlugin)(nil) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go new file mode 100644 index 000000000..daea04761 --- /dev/null +++ b/plugins/sonic_similarity_adapter_test.go @@ -0,0 +1,110 @@ +//go:build !windows + +package plugins + +import ( + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SonicSimilarityPlugin", Ordered, func() { + var ( + manager *Manager + provider sonic.Provider + ) + + BeforeAll(func() { + manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension) + + var ok bool + provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + Describe("PluginNames", func() { + It("reports the sonic similarity capability", func() { + names := manager.PluginNames(string(CapabilitySonicSimilarity)) + Expect(names).To(ContainElement("test-sonic-similarity")) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns similar tracks from the plugin", func() { + mf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.9)) + Expect(results[2].Similarity).To(Equal(0.8)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns a path between two tracks from the plugin", func() { + startMf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + endMf := &model.MediaFile{ + ID: "track-2", + Title: "Tomorrow Never Knows", + Artist: "The Beatles", + } + + results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.95)) + Expect(results[2].Similarity).To(Equal(0.9)) + }) + }) +}) + +var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { + var ( + errorManager *Manager + errorProvider sonic.Provider + ) + + BeforeAll(func() { + errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{ + "test-sonic-similarity": { + "error": "simulated plugin error", + }, + }, "test-sonic-similarity"+PackageExtension) + + var ok bool + errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + It("returns error from GetSonicSimilarTracks", func() { + mf := &model.MediaFile{ID: "track-1", Title: "Test"} + _, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from FindSonicPath", func() { + startMf := &model.MediaFile{ID: "track-1", Title: "Start"} + endMf := &model.MediaFile{ID: "track-2", Title: "End"} + _, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) +}) diff --git a/plugins/testdata/test-sonic-similarity/go.mod b/plugins/testdata/test-sonic-similarity/go.mod new file mode 100644 index 000000000..6265288f9 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.mod @@ -0,0 +1,16 @@ +module test-sonic-similarity + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-sonic-similarity/go.sum b/plugins/testdata/test-sonic-similarity/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go new file mode 100644 index 000000000..e315bff15 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -0,0 +1,71 @@ +// Test plugin for Navidrome sonic similarity integration tests. +// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "errors" + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" +) + +func init() { + sonicsimilarity.Register(&testSonicSimilarity{}) +} + +type testSonicSimilarity struct{} + +func checkConfigError() error { + errMsg, hasErr := pdk.GetConfig("error") + if !hasErr || errMsg == "" { + return nil + } + return errors.New(errMsg) +} + +func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "similar-track-" + strconv.Itoa(i+1), + Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), + Artist: input.Song.Artist, + }, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "path-track-" + strconv.Itoa(i+1), + Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), + Artist: input.StartSong.Artist, + }, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func main() {} diff --git a/plugins/testdata/test-sonic-similarity/manifest.json b/plugins/testdata/test-sonic-similarity/manifest.json new file mode 100644 index 000000000..a55c4021f --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Test Sonic Similarity", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for sonic similarity integration testing", + "capabilities": ["SonicSimilarity"] +} diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4ad9e3daa..4b003167a 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -519,6 +519,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(nil), decider, + nil, ) } diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go new file mode 100644 index 000000000..3a010bc67 --- /dev/null +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -0,0 +1,272 @@ +package e2e + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service +// backed by the given provider and the shared e2e DataStore. +func buildSonicRouter(provider sonic.Provider) *subsonic.Router { + loader := &mockSonicPluginLoader{provider: provider} + m := matcher.New(ds) + sonicSvc := sonic.New(ds, loader, m) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + return subsonic.New( + ds, + noopArtwork{}, + &spyStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + nil, // scanner + events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + noopPlayTracker{}, + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), + decider, + sonicSvc, + ) +} + +// doSonicReq makes a request through a sonic-enabled router and returns the parsed response. +func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder. +func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return w +} + +var _ = Describe("Sonic Similarity Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Context("without sonic similarity plugin", func() { + Describe("getSonicSimilarTracks", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("getSonicSimilarTracks", "id", "any-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("findSonicPath", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Context("with sonic similarity plugin", func() { + var ( + sonicRouter *subsonic.Router + comeTogether model.MediaFile + something model.MediaFile + ) + + BeforeEach(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + comeTogether = songs[0] + + songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Something"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + something = songs[0] + + provider := &mockSonicProvider{ + similarIDs: []string{something.ID, comeTogether.ID}, + pathIDs: []string{comeTogether.ID, something.ID}, + } + sonicRouter = buildSonicRouter(provider) + }) + + Describe("getSonicSimilarTracks", func() { + It("returns similar tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Something")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Come Together")) + Expect(matches[1].Similarity).To(Equal(0.9)) + }) + + It("respects the count parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(*resp.SonicMatches).To(HaveLen(1)) + }) + + It("returns an error for a missing id parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent song ID", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns correct JSON structure", func() { + w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + matches := *wrapper.Subsonic.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Similarity).To(BeNumerically(">", 0)) + Expect(matches[0].Entry.Id).ToNot(BeEmpty()) + }) + }) + + Describe("findSonicPath", func() { + It("returns a path between two tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Come Together")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Something")) + Expect(matches[1].Similarity).To(Equal(0.95)) + }) + + It("returns an error for a missing startSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a missing endSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent start song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", "non-existent-id", + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent end song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", "non-existent-id", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + }) +}) + +// mockSonicProvider returns results using IDs from the real test library, +// so that the matcher can resolve them back to actual MediaFiles. +type mockSonicProvider struct { + similarIDs []string + pathIDs []string +} + +func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.similarIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return results, nil +} + +func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.pathIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return results, nil +} + +type mockSonicPluginLoader struct { + provider sonic.Provider +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" && m.provider != nil { + return []string{"mock-sonic"} + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) { + if m.provider != nil { + return m.provider, true + } + return nil, false +} diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index ae4ef9bb9..220376b15 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e91c02aa4..5b6565260 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -52,12 +53,14 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + sonic *sonicsvc.Sonic } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, + sonic *sonicsvc.Sonic, ) *Router { r := &Router{ ds: ds, @@ -75,6 +78,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea metrics: metrics, lyrics: lyrics, transcodeDecision: transcodeDecision, + sonic: sonic, } r.Handler = r.routes() return r @@ -121,6 +125,8 @@ func (api *Router) routes() http.Handler { h(r, "getTopSongs", api.GetTopSongs) h(r, "getSimilarSongs", api.GetSimilarSongs) h(r, "getSimilarSongs2", api.GetSimilarSongs2) + hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks) + hr(r, "findSonicPath", api.FindSonicPath) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index e110e2b93..1c513edf4 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7f64fb47f..589a609da 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 353cf1077..6c54d36d0 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,12 +8,18 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, } + if api.sonic != nil && api.sonic.HasProvider() { + extensions = append(extensions, responses.OpenSubsonicExtension{ + Name: "sonicSimilarity", Versions: []int32{1}, + }) + } + response.OpenSubsonicExtensions = &extensions return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 92d1c3e84..d98599f8f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -5,12 +5,28 @@ import ( "net/http" "net/http/httptest" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +type mockSonicPluginLoader struct { + names []string +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonicsvc.Provider, bool) { + return nil, false +} + var _ = Describe("GetOpenSubsonicExtensions", func() { var ( router *subsonic.Router @@ -18,29 +34,64 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { r *http.Request ) - BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + JustBeforeEach(func() { w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) - It("should return the correct OpenSubsonicExtensions", func() { - router.ServeHTTP(w, r) + Context("without sonic similarity plugin", func() { + BeforeEach(func() { + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + }) - // Make sure the endpoint is public, by not passing any authentication - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + router.ServeHTTP(w, r) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) + // Make sure the endpoint is public, by not passing any authentication + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(5), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 6 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(6), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + )) + }) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 3f2a2068e..1d5f6a70a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -258,7 +258,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON new file mode 100644 index 000000000..b22f18e83 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON @@ -0,0 +1,25 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [ + { + "entry": { + "id": "1", + "isDir": false, + "title": "Bohemian Rhapsody" + }, + "similarity": 0.95 + }, + { + "entry": { + "id": "2", + "isDir": false, + "title": "We Will Rock You" + }, + "similarity": 0.78 + } + ] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML new file mode 100644 index 000000000..14d17d00d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML @@ -0,0 +1,10 @@ + + + + 0.95 + + + + 0.78 + + diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON new file mode 100644 index 000000000..75562871f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML new file mode 100644 index 000000000..184228a0e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML @@ -0,0 +1 @@ + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index f0bb26f66..c2e863b0f 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -62,6 +62,7 @@ type Subsonic struct { LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` + SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` } const ( @@ -441,6 +442,11 @@ type TopSongs struct { Song []Child `xml:"song,omitempty" json:"song,omitempty"` } +type SonicMatch struct { + Entry Child `xml:"entry" json:"entry"` + Similarity float64 `xml:"similarity" json:"similarity"` +} + type PlayQueue struct { Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` Current string `xml:"current,attr,omitempty" json:"current,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 15f2da9c6..9910ced14 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1114,4 +1114,33 @@ var _ = Describe("Responses", func() { }) }) + Describe("SonicMatches", func() { + Context("without data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{ + {Entry: Child{Id: "1", Title: "Bohemian Rhapsody", IsDir: false}, Similarity: 0.95}, + {Entry: Child{Id: "2", Title: "We Will Rock You", IsDir: false}, Similarity: 0.78}, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + }) diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index ab40a726f..9a9c6af6f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/sonic_similarity.go b/server/subsonic/sonic_similarity.go new file mode 100644 index 000000000..3640a116b --- /dev/null +++ b/server/subsonic/sonic_similarity.go @@ -0,0 +1,69 @@ +package subsonic + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) GetSonicSimilarTracks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + count := p.IntOr("count", 10) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func (api *Router) FindSonicPath(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + startSongID, err := p.String("startSongId") + if err != nil { + return nil, err + } + endSongID, err := p.String("endSongId") + if err != nil { + return nil, err + } + count := p.IntOr("count", 25) + + matches, err := api.sonic.FindSonicPath(ctx, startSongID, endSongID, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func sonicMatchResponse(ctx context.Context, matches []sonic.SimilarMatch) *responses.Subsonic { + response := newResponse() + resp := make(responses.Array[responses.SonicMatch], len(matches)) + for i, m := range matches { + resp[i] = responses.SonicMatch{ + Entry: childFromMediaFile(ctx, m.MediaFile), + Similarity: m.Similarity, + } + } + response.SonicMatches = &resp + return response +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 15ba168d7..adc7b7600 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -27,7 +27,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) w = httptest.NewRecorder() }) @@ -284,7 +284,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -301,7 +301,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", From 0fe08bfa74d5f28d42e4cfb5acbad3fb44e5cc22 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:21:32 -0600 Subject: [PATCH 14/28] feat(ui): add Not Starred filter option (#5362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add Not Starred filter option - #5108 Signed-off-by: Daniel Banariba * fix(ui): apply notStarred translation to playlistTrack resource - #5108 Signed-off-by: Daniel Banariba * refactor(ui): use NullableBooleanInput for starred filter - #5108 Replace QuickFilter approach with NullableBooleanInput per maintainer review feedback. Single tri-state filter (Yes/No/Any) instead of two separate buttons + dataProvider translation. Matches the existing pattern used by the 'missing' filter. Signed-off-by: Daniel Banariba --------- Signed-off-by: Daniel Banariba Co-authored-by: Deluan Quintão --- ui/src/album/AlbumList.jsx | 4 +--- ui/src/artist/ArtistList.jsx | 4 +--- ui/src/song/SongList.jsx | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 28a24981c..0b8c256df 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -20,7 +20,6 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, - QuickFilter, Title, useAlbumsPerPage, useResourceRefresh, @@ -152,10 +151,9 @@ const AlbumFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 6c526a5a5..4559aa8e6 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -24,7 +24,6 @@ import { ArtistContextMenu, CoverArtAvatar, List, - QuickFilter, useGetHandleArtistClick, RatingField, useSelectedFields, @@ -88,10 +87,9 @@ const ArtistFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 98684132f..d928af549 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -20,7 +20,6 @@ import { SongContextMenu, SongDatagrid, SongInfo, - QuickFilter, SongTitleField, SongSimpleList, RatingField, @@ -119,10 +118,9 @@ const SongFilter = (props) => { /> {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } From 5c4f0298a66fc0a081902cd9faafa11ae534efd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 19:36:57 -0400 Subject: [PATCH 15/28] fix(sharing): validate JWT expiration and share existence on stream endpoint (#5426) * fix(sharing): validate JWT expiration and share existence on stream endpoint The public stream endpoint (/public/s/{token}) was using TokenAuth.Decode() which only verifies the JWT signature but skips exp claim validation. This allowed expired share stream URLs to remain functional indefinitely. Additionally, deleting a share did not revoke previously issued stream tokens since the handler never performed a server-side share lookup. Fixed by switching decodeStreamInfo() to use auth.Validate() which properly checks the exp claim, and by embedding the share ID ("sid") in stream tokens so the handler can verify the share still exists. Old tokens without the sid claim remain backward compatible but still benefit from expiration validation. * fix(sharing): check share expiration on stream requests Replace the lightweight Exists() check with Get() + expiration validation, so that shares whose ExpiresAt was updated to an earlier time after token issuance are also rejected (410 Gone). Reuses the existing checkShareError handler for consistent error responses. --- core/auth/claims.go | 8 ++ core/auth/claims_test.go | 9 ++ server/public/handle_shares.go | 1 + server/public/handle_streams.go | 22 ++- server/public/handle_streams_test.go | 196 +++++++++++++++++++++++++++ tests/mock_share_repo.go | 13 ++ 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 server/public/handle_streams_test.go diff --git a/core/auth/claims.go b/core/auth/claims.go index c0e4dea7f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -21,6 +21,7 @@ type Claims struct { ID string // "id" - artwork/mediafile ID Format string // "f" - audio format BitRate int // "b" - audio bitrate + ShareID string // "sid" - share ID for share stream tokens } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any { if c.BitRate != 0 { m["b"] = c.BitRate } + if c.ShareID != "" { + m["sid"] = c.ShareID + } return m } @@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims { c.BitRate = int(bf) } } + var sid string + if err := token.Get("sid", &sid); err == nil { + c.ShareID = sid + } return c } diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index cf6b07263..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -28,6 +28,7 @@ var _ = Describe("Claims", func() { Expect(m).NotTo(HaveKey("id")) Expect(m).NotTo(HaveKey("f")) Expect(m).NotTo(HaveKey("b")) + Expect(m).NotTo(HaveKey("sid")) }) It("includes expiration and issued-at when set", func() { @@ -52,6 +53,12 @@ var _ = Describe("Claims", func() { Expect(m).To(HaveKeyWithValue("f", "mp3")) Expect(m).To(HaveKeyWithValue("b", 192)) }) + + It("includes share ID claim when set", func() { + c := auth.Claims{ShareID: "abc1234567"} + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("sid", "abc1234567")) + }) }) Describe("ClaimsFromToken", func() { @@ -84,6 +91,7 @@ var _ = Describe("Claims", func() { ID: "al-456", Format: "opus", BitRate: 128, + ShareID: "abc1234567", } token, _, err := tokenAuth.Encode(original.ToMap()) Expect(err).NotTo(HaveOccurred()) @@ -91,6 +99,7 @@ var _ = Describe("Claims", func() { c := auth.ClaimsFromToken(token) Expect(c.Issuer).To(Equal("ND")) Expect(c.ID).To(Equal("al-456")) + Expect(c.ShareID).To(Equal("abc1234567")) Expect(c.Format).To(Equal("opus")) Expect(c.BitRate).To(Equal(128)) }) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 24ecff1d6..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -102,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string { ID: id, Format: s.Format, BitRate: s.MaxBitRate, + ShareID: s.ID, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index daa09c375..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -4,11 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + if info.shareID != "" { + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + } + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -63,17 +77,14 @@ type shareTrackInfo struct { id string format string bitrate int + shareID string } func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - token, err := auth.TokenAuth.Decode(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } - if token == nil { - return shareTrackInfo{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } @@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { id: c.ID, format: c.Format, bitrate: c.BitRate, + shareID: c.ShareID, }, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go new file mode 100644 index 000000000..222d5ef1a --- /dev/null +++ b/server/public/handle_streams_test.go @@ -0,0 +1,196 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockStreamer struct { + req stream.Request + called bool +} + +func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) { + m.called = true + m.req = r + return nil, errors.New("mock: not implemented") +} + +var _ = Describe("decodeStreamInfo", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("decodes a valid token with all fields", func() { + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(192)) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("accepts a token without exp (non-expiring share)", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects a token without an id claim", func() { + claims := auth.Claims{ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an invalid token string", func() { + _, err := decodeStreamInfo("not-a-valid-token") + Expect(err).To(HaveOccurred()) + }) + + It("handles tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123", Format: "opus"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("opus")) + Expect(info.shareID).To(BeEmpty()) + }) +}) + +var _ = Describe("encodeMediafileShare", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("includes the share ID in the token", func() { + exp := P(time.Now().Add(time.Hour)) + s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} + token := encodeMediafileShare(s, "mf-999") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareABC")) + Expect(info.id).To(Equal("mf-999")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(320)) + }) + + It("creates a non-expiring token when share has no expiry", func() { + s := model.Share{ID: "shareXYZ", ExpiresAt: nil} + token := encodeMediafileShare(s, "mf-111") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareXYZ")) + Expect(info.id).To(Equal("mf-111")) + }) +}) + +var _ = Describe("handleStream", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var streamer *mockStreamer + var pub *Router + + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + streamer = &mockStreamer{} + pub = &Router{ds: ds, streamer: streamer} + }) + + makeRequest := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil) + w := httptest.NewRecorder() + pub.handleStream(w, r) + return w + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo + + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("returns 400 for an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 when share has been deleted", func() { + shareRepo.ID = "other-share" + claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when share has been set to expired", func() { + shareRepo.ID = "share123" + expired := time.Now().Add(-time.Hour) + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 500 when share lookup fails", func() { + shareRepo.Error = errors.New("db error") + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("skips share check for tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for an invalid token", func() { + w := makeRequest("not-a-valid-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 22eb9446c..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -44,3 +44,16 @@ func (m *MockShareRepo) Exists(id string) (bool, error) { } return id == m.ID, nil } + +func (m *MockShareRepo) Get(id string) (*model.Share, error) { + if m.Error != nil { + return nil, m.Error + } + if id != m.ID { + return nil, model.ErrNotFound + } + if s, ok := m.Entity.(*model.Share); ok { + return s, nil + } + return &model.Share{ID: id}, nil +} From 3e25ca38684d5e883db9bb416ea1ba61070dca93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 20:19:28 -0400 Subject: [PATCH 16/28] test: enable Subsonic response snapshot tests on Windows (#5427) * fix(test): enable Subsonic response snapshot tests on Windows Replaced cupaloy with a simple custom snapshot matcher that normalizes CRLF line endings before comparison. The tests were skipped on Windows via a //go:build unix tag because Git for Windows checks out snapshot files with CRLF, while Go's xml/json.MarshalIndent always produces LF, causing direct string comparison to fail. The new matcher reads snapshot files with os.ReadFile and normalizes \r\n to \n before comparing. Also added a .gitattributes in the .snapshots directory to enforce LF checkout, and removed the now-unused cupaloy dependency. * fix(test): add UPDATE_SNAPSHOTS support to custom snapshot matcher Restore the ability to update snapshots via `make snapshots` (UPDATE_SNAPSHOTS=true), which was lost when replacing cupaloy with the custom matcher. --- go.mod | 3 +- go.sum | 5 +- .../responses/.snapshots/.gitattributes | 1 + .../responses/responses_suite_test.go | 63 +++++++++++++++---- server/subsonic/responses/responses_test.go | 5 -- 5 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/.gitattributes diff --git a/go.mod b/go.mod index b7dbb9eeb..b1d7d3516 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.3 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 @@ -42,6 +41,7 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.3.0 + github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 @@ -112,7 +112,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect diff --git a/go.sum b/go.sum index 29b979413..6ce31dc22 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -205,9 +203,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= diff --git a/server/subsonic/responses/.snapshots/.gitattributes b/server/subsonic/responses/.snapshots/.gitattributes new file mode 100644 index 000000000..44b4224b1 --- /dev/null +++ b/server/subsonic/responses/.snapshots/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index e10957af2..7fc10aa15 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -1,14 +1,17 @@ package responses import ( + "fmt" + "os" + "path/filepath" "strings" "testing" - "github.com/bradleyjkemp/cupaloy/v2" "github.com/navidrome/navidrome/log" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/types" + "github.com/pmezard/go-difflib/difflib" ) func TestSubsonicApiResponses(t *testing.T) { @@ -18,25 +21,61 @@ func TestSubsonicApiResponses(t *testing.T) { } func MatchSnapshot() types.GomegaMatcher { - c := cupaloy.New(cupaloy.FailOnUpdate(false)) - return &snapshotMatcher{c} + return &snapshotMatcher{} } type snapshotMatcher struct { - c *cupaloy.Config + diff string } -func (matcher snapshotMatcher) Match(actual any) (success bool, err error) { - actualJson := strings.TrimSpace(string(actual.([]byte))) - err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) - success = err == nil - return +func (matcher *snapshotMatcher) Match(actual any) (success bool, err error) { + actualBytes, ok := actual.([]byte) + if !ok { + return false, fmt.Errorf("MatchSnapshot expects []byte, got %T", actual) + } + + name := ginkgo.CurrentSpecReport().FullText() + actualStr := strings.TrimSpace(string(actualBytes)) + + snapshotPath := filepath.Join(".snapshots", name) + + // Support UPDATE_SNAPSHOTS=true for `make snapshots` + if _, update := os.LookupEnv("UPDATE_SNAPSHOTS"); update { + err := os.MkdirAll(".snapshots", os.ModePerm) + if err != nil { + return false, err + } + return true, os.WriteFile(snapshotPath, []byte(actualStr+"\n"), 0600) + } + + expected, err := os.ReadFile(snapshotPath) + if err != nil { + return false, err + } + + // Normalize line endings so snapshots work on Windows (CRLF) and Unix (LF) + normalizedExpected := strings.ReplaceAll(string(expected), "\r\n", "\n") + normalizedExpected = strings.TrimSpace(normalizedExpected) + + if actualStr == normalizedExpected { + return true, nil + } + + diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(normalizedExpected), + B: difflib.SplitLines(actualStr), + FromFile: "snapshot", + ToFile: "actual", + Context: 3, + }) + matcher.diff = diff + return false, nil } -func (matcher snapshotMatcher) FailureMessage(_ any) (message string) { - return "Expected to match saved snapshot\n" +func (matcher *snapshotMatcher) FailureMessage(_ any) (message string) { + return "Expected to match saved snapshot\n" + matcher.diff } -func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) { +func (matcher *snapshotMatcher) NegatedFailureMessage(_ any) (message string) { return "Expected to not match saved snapshot\n" } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 9910ced14..ba86deaf2 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1,8 +1,3 @@ -//go:build unix - -// TODO Fix snapshot tests in Windows -// Response Snapshot tests. Only run in Linux and macOS, as they fail in Windows -// Probably because of EOL char differences package responses_test import ( From a4c1fa63787136e3fa9c86d63c31e3956fa1bba0 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 27 Apr 2026 20:24:01 -0400 Subject: [PATCH 17/28] chore(deps): update dependencies to latest versions Signed-off-by: Deluan --- go.mod | 8 ++++---- go.sum | 34 ++++++++++++++++------------------ 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index b1d7d3516..a4c0c014b 100644 --- a/go.mod +++ b/go.mod @@ -34,16 +34,16 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.0.13 + github.com/lestrrat-go/jwx/v3 v3.1.0 github.com/mattn/go-sqlite3 v1.14.42 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/ginkgo/v2 v2.28.2 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.3.0 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.0 + github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -114,7 +114,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect diff --git a/go.sum b/go.sum index 6ce31dc22..3e665ba02 100644 --- a/go.sum +++ b/go.sum @@ -139,8 +139,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -167,14 +167,14 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= -github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= +github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -193,8 +193,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= +github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= @@ -207,16 +207,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= -github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -318,8 +318,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -419,11 +417,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= -modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= From d5ba61adf818e70f1d3d415066f6f342d70ac528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 21:45:13 -0400 Subject: [PATCH 18/28] fix(ui): prevent autoplay when clearing the play queue (#5430) When clearing the queue, the reducer resets to initialState which lacks an autoPlay field (undefined). The autoPlay option was computed as true because undefined !== false, causing the music player library to attempt playback of the first song before internal state was fully cleared. Added a queue.length > 0 guard to the autoPlay calculation so it is never true when the queue is empty, regardless of other state flags. Fixes #5331 --- ui/src/audioplayer/Player.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index eba3b82d7..5599b9e1d 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -210,6 +210,7 @@ const Player = () => { audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: + playerState.queue.length > 0 && playerState.autoPlay !== false && (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, From 46b4dcd5f67182611c077607601491d9d95944c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 28 Apr 2026 19:40:08 -0400 Subject: [PATCH 19/28] feat(smartplaylist): add isMissing and isPresent operators (#5436) * feat(smartplaylist): add IsMissing and IsPresent operator types Add two new Expression types for detecting absent/present tags and roles in smart playlist criteria. Includes JSON marshal/unmarshal support and Walk visitor registration. * test(smartplaylist): add JSON marshal/unmarshal tests for isMissing/isPresent * feat(smartplaylist): add SQL generation for isMissing/isPresent operators Tags check json_tree(media_file.tags) for key existence. Roles check json_tree(media_file.participants) for key existence. Regular DB column fields are rejected with an error. * test(smartplaylist): add e2e tests for isMissing/isPresent operators Tests cover tag presence/absence with selective matching (grouping), universal absence (lyricist role), universal presence (composer role), and combined operator usage. * refactor(smartplaylist): use strconv.ParseBool in IsTruthy Replace hand-rolled string truthiness check with strconv.ParseBool, which correctly handles standard boolean strings and rejects unrecognized values as false. * refactor(smartplaylist): clarify missingExpr parameter naming Rename defaultNegate to checkAbsence and extract truthy local for readability. The XNOR logic (checkAbsence == truthy) is now easier to follow: isMissing passes true, isPresent passes false. * refactor(smartplaylist): reuse jsonExpr in missingExpr, improve errors - tagCond/roleCond now handle nil cond (existence-only check) - missingExpr delegates to jsonExpr(info, nil, negate) instead of building SQL manually - Better error messages: unknown fields now report the field name --- model/criteria/json.go | 4 +++ model/criteria/operators.go | 35 +++++++++++++++++++- model/criteria/operators_test.go | 4 +++ model/criteria/walk.go | 2 +- persistence/criteria_sql.go | 46 +++++++++++++++++++++++---- persistence/criteria_sql_test.go | 30 +++++++++++++++++ persistence/e2e/e2e_suite_test.go | 4 +-- persistence/e2e/smartplaylist_test.go | 41 ++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 10 deletions(-) diff --git a/model/criteria/json.go b/model/criteria/json.go index f6ab56eda..18a664988 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -69,6 +69,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { return InPlaylist(m) case "notinplaylist": return NotInPlaylist(m) + case "ismissing": + return IsMissing(m) + case "ispresent": + return IsPresent(m) } return nil } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 983c6aa1a..7def18934 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,6 +1,9 @@ package criteria -import "time" +import ( + "strconv" + "time" +) // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -162,6 +165,36 @@ func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { func (nipl NotInPlaylist) fields() map[string]any { return nipl } +type IsMissing map[string]any + +func (im IsMissing) MarshalJSON() ([]byte, error) { + return marshalExpression("isMissing", im) +} + +func (im IsMissing) fields() map[string]any { return im } + +type IsPresent map[string]any + +func (ip IsPresent) MarshalJSON() ([]byte, error) { + return marshalExpression("isPresent", ip) +} + +func (ip IsPresent) fields() map[string]any { return ip } + +func IsTruthy(v any) bool { + switch val := v.(type) { + case bool: + return val + case float64: + return val != 0 + case string: + b, err := strconv.ParseBool(val) + return err == nil && b + default: + return v != nil + } +} + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index c93e8f2b2..bfdca3e31 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -46,5 +46,9 @@ var _ = Describe("Operators", func() { Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`), Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`), Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`), + Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`), + Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`), + Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), + Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) }) diff --git a/model/criteria/walk.go b/model/criteria/walk.go index acaf48289..7445c2aef 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -24,7 +24,7 @@ func Walk(expr Expression, visit Visitor) error { return err } } - case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent: return nil default: return fmt.Errorf("unknown criteria expression type %T", expr) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 1431f0d0e..8fca6b2a2 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -185,6 +185,10 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz return c.inList(e, false) case criteria.NotInPlaylist: return c.inList(e, true) + case criteria.IsMissing: + return missingExpr(e, true) + case criteria.IsPresent: + return missingExpr(e, false) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } @@ -201,6 +205,22 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } +func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { + field, value, info, ok := singleField(values) + if !ok { + if len(values) != 1 { + return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field") + } + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if !info.IsTag && !info.IsRole { + return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + } + + negate := checkAbsence == criteria.IsTruthy(value) + return jsonExpr(info, nil, negate), nil +} + func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil @@ -327,11 +347,18 @@ type tagCond struct { } func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - if e.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag) } - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) if e.not { cond = "not " + cond } @@ -345,8 +372,15 @@ type roleCond struct { } func (e roleCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + } if e.not { cond = "not " + cond } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e02032d9a..a2212c4e5 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,26 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // isMissing — tags + Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isMissing tag [false]", criteria.IsMissing{"genre": false}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isMissing — roles + Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, + "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, + "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + // isPresent — tags + Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isPresent tag [false]", criteria.IsPresent{"genre": false}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isPresent — roles + Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, + "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, + "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), ) Describe("playlist permissions", func() { @@ -115,6 +135,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError("invalid field in criteria: unknown")) }) + It("returns an error when isMissing is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + + It("returns an error when isPresent is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index ea0d0fea8..f42292f02 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -116,9 +116,9 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 086e73703..a844dc982 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -330,4 +330,45 @@ var _ = Describe("Smart Playlists", func() { }) }) + + Describe("isMissing/isPresent operators", func() { + It("isMissing finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent false finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing returns all tracks for a tag nobody has", func() { + results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent returns all tracks for a role everyone has", func() { + results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("combines isMissing with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) From d9dac44456187beb98331811a64c287dc3767f6d Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 19:46:12 -0400 Subject: [PATCH 20/28] feat(smartplaylist): add ReplayGain fields to criteria system Expose the four ReplayGain database columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) as first-class numeric criteria fields for smart playlists. This allows users to filter tracks by ReplayGain values, e.g. finding tracks with album gain below a threshold. --- model/criteria/fields.go | 4 ++++ persistence/criteria_sql.go | 4 ++++ persistence/criteria_sql_test.go | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 20c0048b3..37b4cc017 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -72,6 +72,10 @@ var fieldMap = map[string]FieldInfo{ "mbz_recording_id": {Name: "mbz_recording_id"}, "mbz_release_track_id": {Name: "mbz_release_track_id"}, "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, + "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, + "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, + "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 8fca6b2a2..123ec29eb 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -111,6 +111,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "rgalbumgain": {expr: "media_file.rg_album_gain"}, + "rgalbumpeak": {expr: "media_file.rg_album_peak"}, + "rgtrackgain": {expr: "media_file.rg_track_gain"}, + "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, "value": {expr: "value"}, diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index a2212c4e5..c93465735 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // ReplayGain fields + Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), + Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), + Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0), // isMissing — tags Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), From 0fd9c6df2eaf653186228d59a4a9a460b6db69d8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 20:22:48 -0400 Subject: [PATCH 21/28] refactor(smartplaylist): clarify FieldInfo naming in criteria package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename FieldInfo.Name to Alias (only meaningful for backward-compat entries like albumtype→releasetype) and FieldInfo.alias to tagAlias (tag names from mappings.yml that resolve to existing fields). Add a Name() method that derives the canonical name from the map key, eliminating 60+ redundant Name declarations where the value matched the key. LookupField now populates the private name field automatically. Signed-off-by: Deluan --- model/criteria/fields.go | 171 +++++++++++++++++-------------- model/criteria/fields_test.go | 12 +-- model/criteria/sort.go | 2 +- persistence/criteria_sql.go | 16 +-- persistence/criteria_sql_test.go | 4 +- 5 files changed, 111 insertions(+), 94 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 37b4cc017..44d43457b 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,87 +2,94 @@ package criteria import "strings" -// FieldInfo contains semantic metadata about a criteria field +// FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Name string + Alias string // If set, this field is a backward-compat alias for another canonical name IsTag bool IsRole bool Numeric bool - alias string + + tagAlias string // If set, a tag name from mappings.yml that resolves to this field + name string // Canonical name, populated by LookupField from the map key +} + +// Name returns the canonical field name (the map key used to register this field). +func (f FieldInfo) Name() string { + return f.name } var fieldMap = map[string]FieldInfo{ - "title": {Name: "title"}, - "album": {Name: "album"}, - "hascoverart": {Name: "hascoverart"}, - "tracknumber": {Name: "tracknumber"}, - "discnumber": {Name: "discnumber"}, - "year": {Name: "year"}, - "date": {Name: "date", alias: "recordingdate"}, - "originalyear": {Name: "originalyear"}, - "originaldate": {Name: "originaldate"}, - "releaseyear": {Name: "releaseyear"}, - "releasedate": {Name: "releasedate"}, - "size": {Name: "size"}, - "compilation": {Name: "compilation"}, - "missing": {Name: "missing"}, - "explicitstatus": {Name: "explicitstatus"}, - "dateadded": {Name: "dateadded"}, - "datemodified": {Name: "datemodified"}, - "discsubtitle": {Name: "discsubtitle"}, - "comment": {Name: "comment"}, - "lyrics": {Name: "lyrics"}, - "sorttitle": {Name: "sorttitle"}, - "sortalbum": {Name: "sortalbum"}, - "sortartist": {Name: "sortartist"}, - "sortalbumartist": {Name: "sortalbumartist"}, - "albumcomment": {Name: "albumcomment"}, - "catalognumber": {Name: "catalognumber"}, - "filepath": {Name: "filepath"}, - "filetype": {Name: "filetype"}, - "codec": {Name: "codec"}, - "duration": {Name: "duration"}, - "bitrate": {Name: "bitrate"}, - "bitdepth": {Name: "bitdepth"}, - "samplerate": {Name: "samplerate"}, - "bpm": {Name: "bpm"}, - "channels": {Name: "channels"}, - "loved": {Name: "loved"}, - "dateloved": {Name: "dateloved"}, - "lastplayed": {Name: "lastplayed"}, - "daterated": {Name: "daterated"}, - "playcount": {Name: "playcount"}, - "rating": {Name: "rating"}, - "averagerating": {Name: "averagerating", Numeric: true}, - "albumrating": {Name: "albumrating"}, - "albumloved": {Name: "albumloved"}, - "albumplaycount": {Name: "albumplaycount"}, - "albumlastplayed": {Name: "albumlastplayed"}, - "albumdateloved": {Name: "albumdateloved"}, - "albumdaterated": {Name: "albumdaterated"}, - "artistrating": {Name: "artistrating"}, - "artistloved": {Name: "artistloved"}, - "artistplaycount": {Name: "artistplaycount"}, - "artistlastplayed": {Name: "artistlastplayed"}, - "artistdateloved": {Name: "artistdateloved"}, - "artistdaterated": {Name: "artistdaterated"}, - "mbz_album_id": {Name: "mbz_album_id"}, - "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, - "mbz_artist_id": {Name: "mbz_artist_id"}, - "mbz_recording_id": {Name: "mbz_recording_id"}, - "mbz_release_track_id": {Name: "mbz_release_track_id"}, - "mbz_release_group_id": {Name: "mbz_release_group_id"}, - "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, - "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, - "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, - "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, - "library_id": {Name: "library_id", Numeric: true}, + "title": {}, + "album": {}, + "hascoverart": {}, + "tracknumber": {}, + "discnumber": {}, + "year": {}, + "date": {tagAlias: "recordingdate"}, + "originalyear": {}, + "originaldate": {}, + "releaseyear": {}, + "releasedate": {}, + "size": {}, + "compilation": {}, + "missing": {}, + "explicitstatus": {}, + "dateadded": {}, + "datemodified": {}, + "discsubtitle": {}, + "comment": {}, + "lyrics": {}, + "sorttitle": {}, + "sortalbum": {}, + "sortartist": {}, + "sortalbumartist": {}, + "albumcomment": {}, + "catalognumber": {}, + "filepath": {}, + "filetype": {}, + "codec": {}, + "duration": {}, + "bitrate": {}, + "bitdepth": {}, + "samplerate": {}, + "bpm": {}, + "channels": {}, + "loved": {}, + "dateloved": {}, + "lastplayed": {}, + "daterated": {}, + "playcount": {}, + "rating": {}, + "averagerating": {Numeric: true}, + "albumrating": {}, + "albumloved": {}, + "albumplaycount": {}, + "albumlastplayed": {}, + "albumdateloved": {}, + "albumdaterated": {}, + "artistrating": {}, + "artistloved": {}, + "artistplaycount": {}, + "artistlastplayed": {}, + "artistdateloved": {}, + "artistdaterated": {}, + "mbz_album_id": {}, + "mbz_album_artist_id": {}, + "mbz_artist_id": {}, + "mbz_recording_id": {}, + "mbz_release_track_id": {}, + "mbz_release_group_id": {}, + "rgalbumgain": {Numeric: true}, + "rgalbumpeak": {Numeric: true}, + "rgtrackgain": {Numeric: true}, + "rgtrackpeak": {Numeric: true}, + "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {Name: "releasetype", IsTag: true}, + "albumtype": {Alias: "releasetype", IsTag: true}, - "random": {Name: "random"}, - "value": {Name: "value"}, + "random": {}, + "value": {}, } // AllFieldNames returns the names of all registered criteria fields. @@ -96,7 +103,15 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { - f, ok := fieldMap[strings.ToLower(name)] + key := strings.ToLower(name) + f, ok := fieldMap[key] + if ok { + if f.Alias != "" { + f.name = f.Alias + } else { + f.name = key + } + } return f, ok } @@ -108,7 +123,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = FieldInfo{Name: name, IsRole: true} + fieldMap[name] = FieldInfo{IsRole: true} } } @@ -120,14 +135,16 @@ func AddTagNames(tagNames []string) { if _, ok := fieldMap[name]; ok { continue } - for _, fm := range fieldMap { - if fm.alias == name { + for key, fm := range fieldMap { + if fm.tagAlias == name { + fm.Alias = key + fm.tagAlias = "" fieldMap[name] = fm break } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = FieldInfo{Name: name, IsTag: true} + fieldMap[name] = FieldInfo{IsTag: true} } } } @@ -140,7 +157,7 @@ func AddNumericTags(tagNames []string) { fm.Numeric = true fieldMap[name] = fm } else { - fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} + fieldMap[name] = FieldInfo{IsTag: true, Numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index ecbeb5857..f60666788 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -11,14 +11,14 @@ var _ = Describe("fields", func() { field, ok := LookupField("Title") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + gomega.Expect(field.Name()).To(gomega.Equal("title")) }) - It("resolves aliases to their semantic field name", func() { + It("resolves aliases to their canonical field name", func() { field, ok := LookupField("albumtype") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.Name()).To(gomega.Equal("releasetype")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -26,7 +26,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("value") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("value")) + gomega.Expect(field.Name()).To(gomega.Equal("value")) }) It("finds registered tag names", func() { @@ -35,7 +35,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_mood") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_mood")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -56,7 +56,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_producer") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go index 05b108cf9..e38fe0551 100644 --- a/model/criteria/sort.go +++ b/model/criteria/sort.go @@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField { if order == "desc" { desc = !desc } - fields = append(fields, SortField{Field: info.Name, Desc: desc}) + fields = append(fields, SortField{Field: info.Name(), Desc: desc}) } if len(fields) == 0 { log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 123ec29eb..4fd8e62e6 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -338,9 +338,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { - return roleCond{role: info.Name, cond: cond, not: negate} + return roleCond{role: info.Name(), cond: cond, not: negate} } - return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} + return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate} } type tagCond struct { @@ -412,7 +412,7 @@ func sqlFields(values map[string]any) (map[string]any, error) { if info.IsTag || info.IsRole { return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) } - sqlField, ok := fieldExpr(info.Name) + sqlField, ok := fieldExpr(info.Name()) if !ok || sqlField == "" { return nil, fmt.Errorf("invalid field in criteria: %s", field) } @@ -431,7 +431,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { if !ok { return smartPlaylistJoinNone } - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok { return smartPlaylistJoinNone } @@ -479,17 +479,17 @@ func sortExpr(sortField string) (string, bool) { if !ok { return "", false } - if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" { return field.order, true } var mapped string switch { case info.IsTag: - mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')" case info.IsRole: - mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')" default: - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok || field.expr == "" { return "", false } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index c93465735..40d21c9bf 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -194,8 +194,8 @@ var _ = Describe("Smart playlist criteria SQL", func() { if info.IsTag || info.IsRole { continue } - _, hasSQLField := smartPlaylistFields[info.Name] - Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) + _, hasSQLField := smartPlaylistFields[info.Name()] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name()) } }) From 57fc85f434111fa7d943e099ba89161166175021 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 20:44:54 -0400 Subject: [PATCH 22/28] refactor(smartplaylist): remove unused 'value' field and clarify 'random' usage Signed-off-by: Deluan --- model/criteria/fields.go | 2 +- model/criteria/fields_test.go | 7 ------- persistence/criteria_sql.go | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 44d43457b..b35913f28 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -88,8 +88,8 @@ var fieldMap = map[string]FieldInfo{ // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Pseudo-field for random sorting "random": {}, - "value": {}, } // AllFieldNames returns the names of all registered criteria fields. diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index f60666788..270a14473 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -22,13 +22,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) - It("finds special fields", func() { - field, ok := LookupField("value") - - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name()).To(gomega.Equal("value")) - }) - It("finds registered tag names", func() { AddTagNames([]string{"task3_mood"}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 4fd8e62e6..9a99cfa8e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -117,7 +117,6 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, - "value": {expr: "value"}, } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { From bdea9ed6a1bbcfdf6c251f117225559b05c07671 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:48:59 -0600 Subject: [PATCH 23/28] fix(ui): show album tile actions on keyboard focus (#5434) * fix(ui): show album tile actions on keyboard focus - #4836 The album grid tile bar (containing play/heart/context-menu buttons) had opacity:0 by default and only became visible on mouse :hover. Keyboard users tabbing through the album grid never saw which tile was focused and could not discover the available actions. Mirrors the existing :hover rule with :focus-within, which matches when the link itself or any descendant (e.g. the play button) has focus. Signed-off-by: Daniel Banariba * fix(ui): also disable pointer events on hidden album tile bar - #4836 Per review feedback (@gemini-code-assist): the tileBar's buttons remained clickable even at opacity:0, causing accidental Play/Menu triggers when a user clicked what looked like the album cover. Set 'pointerEvents: none' on the base tileBar and restore 'auto' on the same selector that turns it visible. Signed-off-by: Daniel Banariba --------- Signed-off-by: Daniel Banariba --- ui/src/album/AlbumGridView.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 9717618fa..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -34,6 +34,7 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, + pointerEvents: 'none', textAlign: 'left', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', @@ -78,8 +79,9 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar': { + '&:hover $tileBar, &:focus-within $tileBar': { opacity: 1, + pointerEvents: 'auto', }, }, albumLink: { From 2307a64da759157ed3adc1033f58e314eda882ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 29 Apr 2026 15:36:39 -0400 Subject: [PATCH 24/28] fix(ui): start new album from track 1 after closing player (#5441) When a user played an album, advanced a few tracks, closed the player, then played a different album, playback started mid-album at the previous track index instead of track 1. The root cause was in reduceSyncQueue: the hasPendingSwitch check compared playIndex against savedPlayIndex, but after clearQueue both reset to 0, making the check falsely conclude no switch was pending. This caused PLAYER_SYNC_QUEUE to prematurely clear the playIndex and clear flags before the music player library could act on them. Fix: also treat clear=true as a signal that a track switch is pending, since it means a new queue was just loaded. --- ui/src/reducers/playerReducer.js | 14 +++-- ui/src/reducers/playerReducer.test.js | 82 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 466a3ec87..d6ab7484b 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -164,13 +164,15 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // Only keep clear and playIndex alive when there is an actual pending - // track switch (playIndex differs from savedPlayIndex). This lets - // PLAYER_PLAY_TRACKS selections survive the sync, while allowing - // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to - // reset immediately and avoid restarting playback. + // Keep clear and playIndex alive when there is a pending track switch. + // A switch is pending when playIndex is set AND either: + // - playIndex differs from savedPlayIndex, OR + // - clear is true (a new queue was loaded, e.g. after clearQueue + playTracks) + // The clear check handles the edge case where both playIndex and + // savedPlayIndex are 0 (close player then play a new album from track 1). const hasPendingSwitch = - state.playIndex != null && state.playIndex !== state.savedPlayIndex + state.playIndex != null && + (state.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 10e9512d7..110ce8c53 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -96,6 +96,88 @@ describe('playerReducer', () => { }) }) + describe('play new album after closing player (issue #5440)', () => { + it('SYNC_QUEUE preserves pending playIndex=0 after clearQueue', () => { + // Scenario: user plays album A, advances to track 3, closes player, + // then plays album B. After clearQueue, savedPlayIndex=0. + // PLAYER_PLAY_TRACKS sets playIndex=0. SYNC_QUEUE must NOT clear it. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, // reset by clearQueue + clear: true, + volume: 1, + } + + const action = { + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo: {}, + audioLists: stateAfterClearThenPlay.queue, + }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for wrong track preserves pending playIndex=0 after clearQueue', () => { + // The music player fires onAudioPlay for the old track (at index 3) + // before switching to the new track at index 0. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + { trackId: 'b4', uuid: 'u4', name: 'B Song 4' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player reports track at index 3 as current (stale callback) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u4', name: 'B Song 4', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for correct track consumes pending playIndex=0', () => { + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player confirms it switched to track at index 0 + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u1', name: 'B Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { From 2b9f32699348d520fc96acbd74be24b12702b02a Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 30 Apr 2026 16:55:10 -0400 Subject: [PATCH 25/28] chore(docker): add app directory to PATH in Dockerfile --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 105656afb..ad1e2a41c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,6 +164,7 @@ RUN touch /.nddockerenv EXPOSE ${ND_PORT} WORKDIR /app +ENV PATH="/app:${PATH}" ENTRYPOINT ["/app/navidrome"] From 94eb6c522b63198bdc4565442d86918ad43156e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 30 Apr 2026 23:04:05 -0400 Subject: [PATCH 26/28] feat(subsonic): implement playbackReport OpenSubsonic extension (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(req): add Float64Or helper for parsing float query params * feat(scrobbler): extend NowPlayingInfo with state/position/rate fields * feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble * feat(responses): add state/positionMs/playbackRate to NowPlayingEntry * feat(subsonic): add reportPlayback endpoint handler * feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response * feat(subsonic): register playbackReport OpenSubsonic extension * test(e2e): add reportPlayback endpoint e2e tests * refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication - Add state constants and exported ValidStates map - Extract remainingTTL() helper (was duplicated 3x) - Merge playing/paused switch cases into single branch - Use Get instead of GetWithParticipants for non-stopped states - Guard NowPlayingCount broadcast with count-change detection - Use cache entry for NowPlaying dispatch instead of extra DB query - Remove redundant Position field from NowPlayingInfo * refactor(scrobbler): skip DB query in playing/paused when playMap has entry * fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache Signed-off-by: Deluan * refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo Signed-off-by: Deluan * fix(play_tracker): check all ReportPlayback errors in tests Replace _ = with explicit error assertions to avoid masking failures in intermediate calls. Signed-off-by: Deluan * test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback Replace noopPlayTracker with a real PlayTracker backed by the E2E database. E2E tests now verify the full round-trip: reportPlayback creates/updates/removes entries visible via getNowPlaying, including state, positionMs, and playbackRate fields. Export NewPlayTracker constructor for use outside the scrobbler package. * fix(play_tracker): account for playback rate in TTL and detect track switches The remainingTTL function now divides remaining time by the playback rate, so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves the TTL). Zero/negative rates default to 1.0. The playing/paused case now checks if the cached MediaFile ID matches the reported mediaId, falling back to a DB fetch when the client switches tracks without sending stopped/starting. Adds parameterized tests for remainingTTL covering rate variations and edge cases. * fix(subsonic): validate positionMs and playbackRate in reportPlayback Reject negative positionMs values and invalid playbackRate values (NaN, Inf, zero, negative) at the API boundary before they reach TTL and position estimation math. Returns clear error messages for each case. * feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters Signed-off-by: Deluan * refactor(play_tracker): replace NowPlaying method with ReportPlayback calls Signed-off-by: Deluan * refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/scrobbler/play_tracker.go | 175 ++++-- core/scrobbler/play_tracker_test.go | 514 ++++++++++++++---- server/e2e/e2e_suite_test.go | 28 +- server/e2e/subsonic_media_annotation_test.go | 111 ++++ server/e2e/subsonic_sonic_similarity_test.go | 3 +- server/subsonic/album_lists.go | 13 +- server/subsonic/api.go | 1 + server/subsonic/media_annotation.go | 72 ++- server/subsonic/media_annotation_test.go | 118 +++- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 62 ++- ...es NowPlaying with data should match .JSON | 23 + ...ses NowPlaying with data should match .XML | 5 + ...NowPlaying without data should match .JSON | 8 + ... NowPlaying without data should match .XML | 3 + server/subsonic/responses/responses.go | 11 +- server/subsonic/responses/responses_test.go | 36 ++ utils/cache/simple_cache.go | 5 + utils/req/req.go | 12 + utils/req/req_test.go | 17 + 20 files changed, 1002 insertions(+), 216 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d1338ca39..462fc85ba 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -3,7 +3,7 @@ package scrobbler import ( "context" "maps" - "sort" + "slices" "sync" "time" @@ -17,13 +17,30 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) +const ( + StateStarting = "starting" + StatePlaying = "playing" + StatePaused = "paused" + StateStopped = "stopped" +) + +var ValidStates = map[string]bool{ + StateStarting: true, + StatePlaying: true, + StatePaused: true, + StateStopped: true, +} + type NowPlayingInfo struct { - MediaFile model.MediaFile - Start time.Time - Position int - Username string - PlayerId string - PlayerName string + MediaFile model.MediaFile + Start time.Time + Username string + PlayerId string + PlayerName string + State string + PositionMs int64 + PlaybackRate float64 + LastReport time.Time } type Submission struct { @@ -31,6 +48,16 @@ type Submission struct { Timestamp time.Time } +type ReportPlaybackParams struct { + MediaId string + PositionMs int64 + State string + PlaybackRate float64 + IgnoreScrobble bool + ClientId string + ClientName string +} + type nowPlayingEntry struct { ctx context.Context userId string @@ -39,9 +66,9 @@ type nowPlayingEntry struct { } type PlayTracker interface { - NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) Submit(ctx context.Context, submissions []Submission) error + ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } // PluginLoader is a minimal interface for plugin manager usage in PlayTracker @@ -72,8 +99,12 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug }) } -// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by -// the GetPlayTracker function above +// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton, +// returned by the GetPlayTracker function above. This constructor is exported for testing. +func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { + return newPlayTracker(ds, broker, pluginManager) +} + func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { m := cache.NewSimpleCache[string, NowPlayingInfo]() p := &playTracker{ @@ -193,36 +224,104 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } -func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) - if err != nil { - log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) - return err +func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { + if rate <= 0 { + rate = 1.0 } + remainingMs := float64(int64(durationSec*1000)-positionMs) / rate + remainingSec := max(int(remainingMs/1000), 0) + return time.Duration(remainingSec+5) * time.Second +} +func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error { + player, _ := request.PlayerFrom(ctx) user, _ := request.UserFrom(ctx) - info := NowPlayingInfo{ - MediaFile: *mf, - Start: time.Now(), - Position: position, - Username: user.UserName, - PlayerId: playerId, - PlayerName: playerName, + clientId := params.ClientId + client := params.ClientName + + now := time.Now() + prevCount := p.playMap.Len() + + switch params.State { + case StateStarting: + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info := NowPlayingInfo{ + MediaFile: *mf, + Start: now, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + if err != nil { + log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StatePlaying, StatePaused: + info, getErr := p.playMap.Get(clientId) + if getErr != nil || info.MediaFile.ID != params.MediaId { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info = NowPlayingInfo{ + MediaFile: *mf, + Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + } + } + info.State = params.State + info.PositionMs = params.PositionMs + info.PlaybackRate = params.PlaybackRate + info.LastReport = now + ttl := 30 * time.Minute + if params.State == StatePlaying { + ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) + } + err := p.playMap.AddWithTTL(clientId, info, ttl) + if err != nil { + log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StateStopped: + if !params.IgnoreScrobble && player.ScrobbleEnabled { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + trackDurationMs := int64(mf.Duration * 1000) + threshold := min(trackDurationMs*50/100, 240_000) + if params.PositionMs >= threshold { + err = p.incPlay(ctx, mf, now) + if err != nil { + log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) + } + p.dispatchScrobble(ctx, mf, now) + } + } + p.playMap.Remove(clientId) } - // Calculate TTL based on remaining track duration. If position exceeds track duration, - // remaining is set to 0 to avoid negative TTL. - remaining := max(int(mf.Duration)-position, 0) - // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. - ttl := time.Duration(remaining+5) * time.Second - _ = p.playMap.AddWithTTL(playerId, info, ttl) - if conf.Server.EnableNowPlaying { + if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - player, _ := request.PlayerFrom(ctx) - if player.ScrobbleEnabled { - p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) + + if !params.IgnoreScrobble && player.ScrobbleEnabled && + (params.State == StateStarting || params.State == StatePlaying) { + if info, err := p.playMap.Get(clientId); err == nil { + p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) + } } + return nil } @@ -296,9 +395,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t * func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { res := p.playMap.Values() - sort.Slice(res, func(i, j int) bool { - return res[i].Start.After(res[j].Start) + slices.SortFunc(res, func(a, b NowPlayingInfo) int { + return b.Start.Compare(a.Start) }) + for i := range res { + if res[i].State == StatePlaying { + elapsed := time.Since(res[i].LastReport).Milliseconds() + estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate) + trackDurationMs := int64(res[i].MediaFile.Duration * 1000) + res[i].PositionMs = min(estimated, trackDurationMs) + } + } return res, nil } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..64b3d3ee0 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -20,9 +20,6 @@ import ( . "github.com/onsi/gomega" ) -// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests -// Moved to top-level scope to avoid linter issues - type mockPluginLoader struct { mu sync.RWMutex names []string @@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) }) - Describe("NowPlaying", func() { - It("sends track to agent", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - Expect(fake.GetUserID()).To(Equal("u-1")) - Expect(fake.GetTrack().ID).To(Equal("123")) - Expect(fake.GetTrack().Participants).To(Equal(track.Participants)) - }) - It("does not send track to agent if user has not authorized", func() { - fake.Authorized = false - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if player is not enabled to send scrobbles", func() { - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if artist is unknown", func() { - track.Artist = consts.UnknownArtist - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - - It("stores position when greater than zero", func() { - pos := 42 - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos)) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].Position).To(Equal(pos)) - }) - - It("sends event with count", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - eventList := eventBroker.getEvents() - Expect(eventList).ToNot(BeEmpty()) - evt, ok := eventList[0].(*events.NowPlayingCount) - Expect(ok).To(BeTrue()) - Expect(evt.Count).To(Equal(1)) - }) - - It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(eventBroker.getEvents()).To(BeEmpty()) - }) - - It("passes user to scrobbler via context (fix for issue #4787)", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - // Verify the username was passed through async dispatch via context - Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser")) - }) - }) - Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) - _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) + ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one", + }) + ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) playing, err := tracker.GetNowPlaying(ctx) @@ -336,6 +263,379 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("ReportPlayback", func() { + const defaultClientId = "client-1" + + BeforeEach(func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true}) + }) + + It("creates entry on starting and removes on stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].MediaFile.ID).To(Equal("123")) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing[0].State).To(Equal("paused")) + Expect(playing[0].PositionMs).To(Equal(int64(30000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("starting replaces existing entry for same player", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("multiple players have independent sessions", func() { + ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true}) + + ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true}) + + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + + err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2", + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(2)) + }) + + Describe("auto-scrobble", func() { + It("scrobbles on stopped when positionMs >= 50% of track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + Expect(artist1.PlayCount).To(Equal(int64(1))) + }) + + It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() { + longTrack := model.MediaFile{ + ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1", + Duration: 600, + Participants: map[model.Role]model.ParticipantList{ + model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, + }, + } + _ = ds.MediaFile(ctx).Put(&longTrack) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(longTrack.PlayCount).To(Equal(int64(1))) + }) + + It("does NOT scrobble when positionMs below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("scrobbles twice for two separate sessions of same song", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(2))) + }) + + It("dispatches to external scrobblers on auto-scrobble", func() { + fake.ScrobbleCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + }) + + Describe("position estimation", func() { + It("estimates position for playing state based on elapsed time", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000))) + }) + + It("does NOT estimate for paused", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(10000))) + }) + + It("does NOT estimate for starting", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("respects playbackRate", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(100 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + // At 2x speed, 100ms real time = ~200ms playback time + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100))) + }) + + It("caps estimated position at track duration", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000 + }) + + }) + + Describe("resilience (no prior starting)", func() { + It("playing without prior starting creates entry with Start approx now - positionMs", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + expectedStart := time.Now().Add(-30 * time.Second) + Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second)) + }) + + It("paused without prior starting creates entry", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("paused")) + }) + + It("stopped without prior starting auto-scrobbles if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("stopped without prior starting does NOT scrobble if below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("external scrobbler dispatch", func() { + It("dispatches NowPlaying on starting", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("dispatches NowPlaying on playing", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("does NOT dispatch on paused", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ignoreScrobble=true", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ScrobbleEnabled=false", func() { + fake.nowPlayingCalled.Store(false) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + }) + }) + Describe("Plugin scrobbler logic", func() { var pluginLoader *mockPluginLoader var pluginFake *fakeScrobbler @@ -354,27 +654,32 @@ var _ = Describe("PlayTracker", func() { }) It("registers and uses plugin scrobbler for NowPlaying", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("removes plugin scrobbler if not present anymore", func() { - // First call: plugin present - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) pluginFake.nowPlayingCalled.Store(false) - // Remove plugin pluginLoader.SetNames([]string{}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - // Should not be called since plugin was removed + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("calls both builtin and plugin scrobblers for NowPlaying", func() { fake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false) - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) @@ -550,6 +855,24 @@ var _ = Describe("PlayTracker", func() { }) }) +var _ = DescribeTable("remainingTTL", + func(durationSec float32, positionMs int64, rate float64, expected time.Duration) { + Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected)) + }, + Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second), + Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second), + Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second), + Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second), + Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second), + Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second), + Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second), + Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second), + Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second), + Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second), + Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second), + Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second), +) + type fakeScrobbler struct { Authorized bool nowPlayingCalled atomic.Bool @@ -577,17 +900,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } -func (f *fakeScrobbler) GetPosition() int { - return int(f.position.Load()) -} - -func (f *fakeScrobbler) GetUsername() string { - if p := f.username.Load(); p != nil { - return *p - } - return "" -} - func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4b003167a..7ce8de2e6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } -// noopPlayTracker implements scrobbler.PlayTracker -type noopPlayTracker struct{} - -func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { - return nil -} - -func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { - return nil, nil -} - -func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { - return nil -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -513,7 +497,7 @@ func setupTestDB() { s, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go index 0f7e92083..ec9b070de 100644 --- a/server/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + + Describe("ReportPlayback", Ordered, func() { + var songID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("returns error when required params are missing", func() { + resp := doReq("reportPlayback") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for invalid state", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "invalid", + ) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("starting report creates a getNowPlaying entry", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.Status).To(Equal(responses.StatusOK)) + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("starting")) + }) + + It("playing report updates getNowPlaying state and position", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "playing", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("playing")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000))) + }) + + It("paused report freezes position in getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "paused", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("paused")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000))) + }) + + It("stopped report removes entry from getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "90000", + "state", "stopped", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(BeEmpty()) + }) + + It("accepts mediaType=podcast without error", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "podcast", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("accepts optional playbackRate and ignoreScrobble", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "5000", + "state", "playing", + "playbackRate", "1.5", + "ignoreScrobble", "true", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5)) + }) + }) }) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 3a010bc67..40161470b 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" @@ -42,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { nil, // scanner events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 56cf469c5..275665b02 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -214,11 +214,14 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { return responses.NowPlayingEntry{ - Child: childFromMediaFile(ctx, np.MediaFile), - UserName: np.Username, - MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything - PlayerName: np.PlayerName, + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerName: np.PlayerName, + State: np.State, + PositionMs: np.PositionMs, + PlaybackRate: np.PlaybackRate, } }) return response, nil diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 5b6565260..0bbfcb83e 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -144,6 +144,7 @@ func (api *Router) routes() http.Handler { h(r, "star", api.Star) h(r, "unstar", api.Unstar) h(r, "scrobble", api.Scrobble) + h(r, "reportPlayback", api.ReportPlayback) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 39bc83fa9..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "math" "net/http" "time" @@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi } log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) - return err + return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: trackId, + PositionMs: int64(position) * 1000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) +} + +func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + mediaId, err := p.String("mediaId") + if err != nil { + return nil, err + } + mediaType, err := p.String("mediaType") + if err != nil { + return nil, err + } + positionMs, err := p.Int64("positionMs") + if err != nil { + return nil, err + } + if positionMs < 0 { + return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative") + } + state, err := p.String("state") + if err != nil { + return nil, err + } + + if !scrobbler.ValidStates[state] { + return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state) + } + + playbackRate := p.Float64Or("playbackRate", 1.0) + if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 { + return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number") + } + ignoreScrobble := p.BoolOr("ignoreScrobble", false) + + ctx := r.Context() + if mediaType != "song" { + log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId) + } + + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mediaId, + PositionMs: positionMs, + State: state, + PlaybackRate: playbackRate, + IgnoreScrobble: ignoreScrobble, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err) + return nil, err + } + + return newResponse(), nil } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1c513edf4..f5cf434e0 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying", func() { + It("registers a NowPlaying via ReportPlayback", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - Expect(playTracker.Playing).To(HaveLen(1)) - Expect(playTracker.Playing).To(HaveKey("player-1")) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12")) + Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1")) }) }) }) + + Describe("ReportPlayback", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when positionMs is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when state is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid state value", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative positionMs", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for NaN playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for Inf playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for zero playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("accepts mediaType=podcast without error", func() { + r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + resp, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + _, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0)) + Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse()) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1")) + Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - Playing map[string]string - Error error -} - -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { - if f.Error != nil { - return f.Error - } - if f.Playing == nil { - f.Playing = make(map[string]string) - } - f.Playing[playerId] = trackId - return nil + Submissions []scrobbler.Submission + ReportedPlayback []scrobbler.ReportPlaybackParams + Error error } func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { @@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm return nil } +func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error { + if f.Error != nil { + return f.Error + } + f.ReportedPlayback = append(f.ReportedPlayback, params) + return nil +} + var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) type fakeEventBroker struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 6c54d36d0..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -14,6 +14,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, + {Name: "playbackReport", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index d98599f8f..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,42 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - Context("with sonic similarity plugin", func() { - BeforeEach(func() { - sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) - }) - - It("should return 6 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -90,6 +61,37 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 7 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(7), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON new file mode 100644 index 000000000..f99cb62af --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON @@ -0,0 +1,23 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "Song", + "username": "testuser", + "minutesAgo": 2, + "playerId": 1, + "playerName": "TestPlayer", + "state": "playing", + "positionMs": 120000, + "playbackRate": 1.5 + } + ] + } +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML new file mode 100644 index 000000000..ae05b68d0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML @@ -0,0 +1,5 @@ + + + + + diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON new file mode 100644 index 000000000..ab8f793fa --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": {} +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML new file mode 100644 index 000000000..763b4b583 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c2e863b0f..b0b2b8752 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -358,10 +358,13 @@ type Starred2 struct { type NowPlayingEntry struct { Child - UserName string `xml:"username,attr" json:"username"` - MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` - PlayerId int32 `xml:"playerId,attr" json:"playerId"` - PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + UserName string `xml:"username,attr" json:"username"` + MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerId int32 `xml:"playerId,attr" json:"playerId"` + PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + State string `xml:"state,attr" json:"state"` + PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` + PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` } type NowPlaying struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ba86deaf2..ee98a3daa 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1109,6 +1109,42 @@ var _ = Describe("Responses", func() { }) }) + Describe("NowPlaying", func() { + BeforeEach(func() { + response.NowPlaying = &NowPlaying{} + }) + + Describe("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + response.NowPlaying.Entry = []NowPlayingEntry{{ + Child: Child{Id: "1", Title: "Song", IsDir: false}, + UserName: "testuser", + MinutesAgo: 2, + PlayerId: 1, + PlayerName: "TestPlayer", + State: "playing", + PositionMs: 120000, + PlaybackRate: 1.5, + }} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + Describe("SonicMatches", func() { Context("without data", func() { BeforeEach(func() { diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index cac41be7b..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface { AddWithTTL(key K, value V, ttl time.Duration) error Get(key K) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) + Remove(key K) Keys() []K Values() []V Len() int @@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error return nil } +func (c *simpleCache[K, V]) Remove(key K) { + c.data.Delete(key) +} + func (c *simpleCache[K, V]) Get(key K) (V, error) { item := c.data.Get(key) if item == nil { diff --git a/utils/req/req.go b/utils/req/req.go index f9fa5724b..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool { } return v } + +func (r *Values) Float64Or(param string, def float64) float64 { + v, err := r.String(param) + if err != nil { + return def + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def + } + return f +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index e710365bd..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() { }) }) + Describe("Float64Or", func() { + It("returns parsed float value", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5)) + }) + + It("returns default when param is missing", func() { + r := req.Params(httptest.NewRequest("GET", "/test", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + + It("returns default when param is not a valid float", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + }) + Describe("ParamBoolPtr", func() { Context("value is true", func() { BeforeEach(func() { From 556f345a10ed600aefd75810b2412c37f05bf8b2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 1 May 2026 14:22:51 -0400 Subject: [PATCH 27/28] chore(lint): upgrade golangci-lint, fix/ignore new errors Signed-off-by: Deluan --- Makefile | 2 +- server/subsonic/api_test.go | 2 +- server/subsonic/media_retrieval.go | 2 +- server/subsonic/middlewares.go | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index ad96afd31..e303017c7 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.11.1 +GOLANGCI_LINT_VERSION ?= v2.12.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 3b88704b1..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() { It("updates status pointer when an error occurs", func() { pointer := int32(0) - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet r = r.WithContext(ctx) payload.Status = responses.StatusFailed diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 3faae1650..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -37,7 +37,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses log.Warn(ctx, "User needs an email for gravatar to work", "username", username) return api.getPlaceHolderAvatar(w, r) } - http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) //nolint:gosec // URL is not constructed from user input return nil, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 2d8b1fd94..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -199,7 +199,7 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - cookie := &http.Cookie{ + cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, @@ -239,7 +239,9 @@ func playerIDCookieName(userName string) string { return cookieName } -const subsonicErrorPointer = "subsonicErrorPointer" +type contextKey string + +const subsonicErrorPointer contextKey = "subsonicErrorPointer" func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { From 7e16b6acb5c11e283fcd320a0abb82372a8ab0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 1 May 2026 15:27:32 -0400 Subject: [PATCH 28/28] feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add UIPlaybackReportInterval setting * feat(server): expose playbackReportIntervalMs to UI config * feat(ui): add playbackReportIntervalMs config default * feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer * feat(ui): replace scrobble logic with reportPlayback state machine in Player * refactor(ui): simplify Player heartbeat using useInterval hook - Replace manual setInterval/clearInterval with existing useInterval hook - Extract shared reportPlaybackUrl helper to deduplicate URL construction - Use ref for currentTrackId in beforeunload to stabilize effect deps - Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime * feat(ui): redesign NowPlaying panel with Discord-style layout Show album art with play/pause overlay icon, track title, artist, album name, progress bar with position/duration, and username. * fix(ui): adjust NowPlaying panel height to fit 3 entries * fix(ui): send stopped on player close and tab close while paused - onBeforeDestroy now sends reportPlayback stopped before clearing queue - beforeunload sends stopped beacon regardless of pause state * feat(ui): animate NowPlaying progress bar with 1s client-side tick * fix(ui): account for playbackRate in NowPlaying progress interpolation * fix(ui): use timestamp-based interpolation for smooth NowPlaying progress Replace tick counter with fetchedAt timestamp so the progress bar advances smoothly without resetting on each server poll. * fix(ui): fix NowPlaying progress bar not animating Pass `now` (Date.now()) as a prop that changes every tick, so React.memo'd components actually re-render each second. * fix(ui): prevent progress bar reset on NowPlaying poll Set fetchedAt and now atomically on fetch so the elapsed offset starts at zero and the server's already-estimated positionMs is used as the base without a visible jump. * fix(ui): stamp entries with fetch time to prevent progress bar reset Embed _fetchedAt timestamp directly into each entry object so the position and its reference timestamp are always in the same state update, eliminating the React 17 multi-setState batching race. * fix(server): estimate position for starting state in GetNowPlaying GetNowPlaying was only estimating elapsed position for the "playing" state, returning raw positionMs=0 for "starting". Since the UI player sends "starting" once and then doesn't update until the 60s heartbeat, NowPlaying polls returned 0 for up to a minute, causing the progress bar to reset on every poll. * fix(ui): send playing immediately after starting to enable position estimation The server only estimates elapsed position for "playing" state in GetNowPlaying. The Player was sending "starting" once and then not updating until the 60s heartbeat, leaving the server state as "starting" with positionMs=0 for up to a minute. Now the Player follows up "starting" with an immediate "playing" call, transitioning the server state so position estimation works from the first poll. * fix(subsonic): fix getNowPlaying returning same playerId for all entries PlayerId was never incremented in the map callback, so every entry got playerId=1. This caused the UI to use duplicate React keys, mixing up rendered entries between players. Also use a stable composite key in the UI instead of the sequential playerId. * fix(ui): only send stopped beacon when tab is actually closing Move the reportPlaybackBeacon call from beforeunload to pagehide. beforeunload fires before the confirmation dialog, so cancelling the close would still send stopped. pagehide only fires when the page is actually being unloaded. * fix(ui): revert to beforeunload for stopped beacon pagehide does not fire reliably in Chrome when closing tabs. Use beforeunload instead — if the user cancels the close dialog, the heartbeat will re-register the NowPlaying entry on its next tick. * fix(ui): use synchronous XHR for stopped report on tab close Replace sendBeacon with synchronous XMLHttpRequest in beforeunload. This blocks the page from closing until the server acknowledges the stopped state, ensuring the NowPlaying entry is always removed. * fix(ui): fix confirmation dialog and use fetch keepalive for tab close - Move e.preventDefault() before the stopped report so the dialog always shows regardless of XHR errors - Use fetch with keepalive:true instead of sync XHR (more reliable, non-blocking, survives page teardown) - Fall back to sendBeacon if fetch throws * fix(ui): prevent heartbeat from re-adding entry after stopped on tab close Set a stoppedRef flag in beforeunload so the heartbeat interval skips sending playing reports after stopped has been sent. Without this, the heartbeat could re-register the NowPlaying entry after the stopped event removed it. * fix(ui): include client unique ID header in stopped report on tab close Root cause: reportPlaybackSync (fetch keepalive) did not include the X-ND-Client-Unique-Id header. Regular reportPlayback calls via httpClient include this header, and the server uses it as the playMap key. Without the header, the stopped call fell back to player.ID as the key, which didn't match the entry added with the UUID key. The playMap.Remove targeted the wrong key, so the entry persisted. Fix: export clientUniqueId from httpClient and include it as a header in the fetch keepalive request. * fix(ui): use pagehide for stopped report to avoid premature send beforeunload fires before the confirmation dialog, so the stopped event was sent even when the user cancelled closing. Move the stopped report to pagehide, which only fires when the page is actually being unloaded (after confirmation). * feat(server): broadcast NowPlaying SSE on every state change Previously, the SSE broadcast only fired when the NowPlaying count changed. Now it fires on every reportPlayback call (starting, playing, paused, stopped), so the NowPlaying panel gets instant updates for state transitions and position changes. The UI reducer stores a nowPlayingLastUpdate timestamp alongside the count, ensuring every SSE event triggers a re-fetch even when the count is unchanged (e.g., pause/resume). * fix(ui): clamp NowPlaying position to prevent negative time display * fix(ui): debounce NowPlaying fetches to prevent progress bar trembling During track changes, rapid SSE events (stopped, starting, playing) triggered multiple refetches within milliseconds, each resetting the interpolation base and causing the progress bar to oscillate. Skip fetches within 1 second of the previous fetch. * feat(ui): report playback position on seek Send a reportPlayback(playing) call when the user seeks/scrubs in the player, so the NowPlaying panel and server position stay in sync immediately instead of waiting for the next 60s heartbeat. * refactor: code review cleanup - Export clientUniqueIdHeader from httpClient, use in subsonic layer - Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList - Fix onBeforeDestroy nested dep (read isRadio from ref instead) - Only broadcast SSE on state transitions, not heartbeat position updates - Only enqueue NowPlaying to external scrobblers on state transitions Signed-off-by: Deluan * fix(ui): use trailing-edge debounce for NowPlaying fetch Replace the leading-edge throttle (which fetched on the first event and blocked subsequent ones) with a trailing-edge debounce (300ms). During track transitions, the burst of events (stopped → starting → playing) now collapses into a single fetch after the burst settles, showing the new track immediately instead of briefly showing empty. * fix(ui): only show overlay on NowPlaying artwork when paused Signed-off-by: Deluan * refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync * refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive The function was never synchronous — it uses fetch with keepalive:true, which is fire-and-forget. The name now reflects the actual behavior. * style: format code with prettier * test: add tests for reportPlayback SSE broadcast and UI changes - play_tracker: verify SSE broadcast on every state transition and that broadcasts are skipped when EnableNowPlaying is false - activityReducer: verify nowPlayingLastUpdate timestamp is set - subsonic/index: verify reportPlayback URL construction Signed-off-by: Deluan * fix(ui): prevent NowPlaying from fetching every second when panel is open fetchList had unstable identity because it depended on doFetch (which depended on notify/dispatch). Each 1s setNow re-render recreated the callback chain, re-triggering the useEffect that calls fetchList. Use a ref for the fetch logic so fetchList has a stable identity with empty deps. * fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel The fetch dispatched nowPlayingCountUpdate on every result, which updated nowPlayingLastUpdate in Redux, which triggered the SSE effect to call fetchList again — creating a fetch loop. Fix: remove dispatch from fetch results. The badge count uses entries.length (from local state) when entries are loaded, falling back to Redux count (from SSE) when they aren't. SSE events remain the only trigger for nowPlayingLastUpdate, breaking the loop. * fix(ui): clear NowPlaying entries on panel close so badge uses SSE count * style: format code with prettier * fix: address code review feedback - Fix currentTime truthiness check to handle position 0 correctly - Report actual player state (playing/paused) on seek instead of always sending 'playing' - Remove idx from React key to avoid reorder issues - Add debounce timer cleanup on unmount - Keep entries on panel close so badge stays accurate from polling - Fix test description to match actual assertion * fix(ui): keep NowPlaying badge count accurate from polling Add a separate nowPlayingCountSync action that updates the Redux count without setting nowPlayingLastUpdate (which would trigger the SSE effect and cause a fetch loop). Polling results now sync the badge count via this action, so the badge stays accurate even when SSE is unavailable. * style: format code with prettier --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + consts/consts.go | 11 +- core/scrobbler/play_tracker.go | 3 +- core/scrobbler/play_tracker_test.go | 63 +++++ server/serve_index.go | 1 + server/serve_index_test.go | 1 + server/subsonic/album_lists.go | 3 +- ui/src/actions/serverEvents.js | 6 + ui/src/audioplayer/Player.jsx | 170 ++++++++----- ui/src/config.js | 1 + ui/src/dataProvider/httpClient.js | 4 +- ui/src/dataProvider/index.js | 4 +- ui/src/layout/NowPlayingPanel.jsx | 306 +++++++++++++++++------- ui/src/layout/NowPlayingPanel.test.jsx | 50 ++-- ui/src/reducers/activityReducer.js | 8 + ui/src/reducers/activityReducer.test.js | 17 ++ ui/src/subsonic/index.js | 33 ++- ui/src/subsonic/index.test.js | 30 +++ 18 files changed, 517 insertions(+), 196 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 916efe70b..1c4829d82 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -95,6 +95,7 @@ type configOptions struct { EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool + UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -776,6 +777,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) + viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) diff --git a/consts/consts.go b/consts/consts.go index 3db0b831a..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -67,11 +67,12 @@ const ( ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 + DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 462fc85ba..161d04d7d 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -240,7 +240,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP client := params.ClientName now := time.Now() - prevCount := p.playMap.Len() switch params.State { case StateStarting: @@ -311,7 +310,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.playMap.Remove(clientId) } - if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { + if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 64b3d3ee0..734f8f66f 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -371,6 +371,69 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(HaveLen(2)) }) + Describe("SSE broadcast on state change", func() { + BeforeEach(func() { + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + }) + + It("broadcasts NowPlayingCount on every state change", func() { + // starting -> count should be 1 + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts := eventBroker.getEvents() + Expect(evts).To(HaveLen(1)) + Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) + + // playing -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(2)) + Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) + + // paused -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(3)) + Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) + + // stopped -> count should be 0 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(4)) + Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) + }) + + It("does NOT broadcast when EnableNowPlaying is false", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + Describe("auto-scrobble", func() { It("scrobbles on stopped when positionMs >= 50% of track", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ diff --git a/server/serve_index.go b/server/serve_index.go index 734aabc70..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 31bca02cf..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) It("sanitizes entity-encoded welcomeMessage as html", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 275665b02..2a2338d89 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -213,11 +213,12 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response.NowPlaying = &responses.NowPlaying{} var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), UserName: np.Username, MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerId: i, PlayerName: np.PlayerName, State: np.State, PositionMs: np.PositionMs, diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 5599b9e1d..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useInterval } from '../common' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -41,9 +42,11 @@ const Player = () => { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { useEffect(() => { const handleBeforeUnload = (e) => { - // Check there's a current track and is actually playing/not paused if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() - e.returnValue = '' // Chrome requires returnValue to be set + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -227,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const state = audioInstance?.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackId, posMs, state) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -275,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -309,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -369,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -397,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/config.js b/ui/src/config.js index f4d60dfba..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { useTranslate, Link, useNotify } from 'react-admin' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + }, + avatarContainer: { + position: 'relative', + flexShrink: 0, + width: theme.spacing(8), + height: theme.spacing(8), }, avatar: { - width: theme.spacing(6), - height: theme.spacing(6), + width: '100%', + height: '100%', cursor: 'pointer', + borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, + stateOverlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + borderRadius: theme.spacing(0.5), + pointerEvents: 'none', + }, + stateIcon: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 18, + }, + entryContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }, + trackTitle: { + fontWeight: 600, + fontSize: '0.875rem', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + trackDetail: { + fontSize: '0.75rem', + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + artistLink: { + cursor: 'pointer', + color: theme.palette.text.secondary, + fontSize: '0.75rem', + '&:hover': { + textDecoration: 'underline', + }, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + marginTop: theme.spacing(0.5), + }, + progressTime: { + fontSize: '0.65rem', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + }, + progressBar: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: theme.palette.action.disabledBackground, + '& .MuiLinearProgress-bar': { + borderRadius: 2, + }, + }, + userInfo: { + fontSize: '0.65rem', + color: theme.palette.text.disabled, + marginTop: theme.spacing(0.25), + }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + const isPaused = nowPlayingEntry.state === 'paused' + const isPlaying = + nowPlayingEntry.state === 'playing' || + nowPlayingEntry.state === 'starting' + const basePositionMs = nowPlayingEntry.positionMs || 0 + const rate = nowPlayingEntry.playbackRate || 1 + const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) + const interpolatedMs = isPlaying + ? basePositionMs + elapsedSinceFetch * rate + : basePositionMs + const durationMs = (nowPlayingEntry.duration || 0) * 1000 + const clampedMs = Math.max(0, interpolatedMs) + const positionMs = + durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs + const positionSec = positionMs / 1000 + const durationSec = nowPlayingEntry.duration || 0 + const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 + const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId + const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return ( - - + +

- - - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - ) : ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - )} -  - {nowPlayingEntry.title} + {isPaused && ( +
+
- } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} +
+
+ + {nowPlayingEntry.title} + + {artistId ? ( + + {artistName} + + ) : ( + + {artistName} + + )} + + {nowPlayingEntry.album} + +
+ + {formatDuration(positionSec)} + + + + {formatDuration(durationSec)} + +
+ + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + +
) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, album: PropTypes.string, + state: PropTypes.string, + positionMs: PropTypes.number, + duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( - ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( ))} @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { entries: PropTypes.arrayOf(PropTypes.object).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) + const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) const streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` }, []) - const fetchList = useCallback( - () => - subsonic - .getNowPlaying() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - const nowPlayingEntries = data.nowPlaying?.entry || [] - setEntries(nowPlayingEntries) - // Also update the count in Redux store - dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) - } else { - throw new Error( - data.error?.message || 'Failed to fetch now playing data', - ) - } + const fetchTimerRef = useRef(null) + const doFetchRef = useRef() + doFetchRef.current = () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + const fetchTime = Date.now() + setEntries( + nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), + ) + dispatch(nowPlayingCountSync({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + const fetchList = useCallback(() => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + fetchTimerRef.current = setTimeout(() => { + fetchTimerRef.current = null + doFetchRef.current() + }, 300) + }, []) + + useEffect(() => { + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) - // Refresh when count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [lastUpdate, open, fetchList, serverUp]) + + // Update current time every second when open to animate progress bars + useInterval(() => setNow(Date.now()), open ? 1000 : null) // Periodic refresh when panel is open (10 seconds) useInterval( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('', () => { , ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('', () => { , ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 8238e395a..d61ed6018 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -3,6 +3,7 @@ import { EVENT_SCAN_STATUS, EVENT_SERVER_START, EVENT_NOW_PLAYING_COUNT, + EVENT_NOW_PLAYING_COUNT_SYNC, EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -17,6 +18,7 @@ const initialState = { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, // Timestamp of last reconnection } @@ -45,6 +47,12 @@ export const activityReducer = (previousState = initialState, payload) => { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index a750694f4..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -194,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +})