From 43e9ade8a39269754427fe44c3069aa6c8e13caf Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:35:19 -0700 Subject: [PATCH 01/11] initial scrobble api --- model/scrobble.go | 14 +- persistence/persistence.go | 2 + persistence/persistence_suite_test.go | 20 +++ persistence/scrobble_repository.go | 120 ++++++++++++++++ persistence/scrobble_repository_test.go | 181 +++++++++++++++++++++--- server/nativeapi/native_api.go | 1 + tests/mock_scrobble_repo.go | 20 +++ 7 files changed, 332 insertions(+), 26 deletions(-) diff --git a/model/scrobble.go b/model/scrobble.go index e1567abc3..45b219292 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,11 +3,19 @@ package model import "time" type Scrobble struct { - MediaFileID string - UserID string - SubmissionTime time.Time + MediaFileID string `json:"-"` + UserID string `json:"-"` + + SubmissionTime time.Time `structs:"submission_time" json:"submissionTime"` + RowId int64 `structs:"row_id" json:"rowId"` + MediaFile } type ScrobbleRepository interface { + CountAll(options ...QueryOptions) (int64, error) + Get(id string) (*Scrobble, error) + GetAll(options ...QueryOptions) (Scrobbles, error) RecordScrobble(mediaFileID string, submissionTime time.Time) error } + +type Scrobbles []Scrobble diff --git a/persistence/persistence.go b/persistence/persistence.go index 83211bdd5..1164eb70f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -123,6 +123,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository return s.Tag(ctx).(model.ResourceRepository) case model.Plugin: return s.Plugin(ctx).(model.ResourceRepository) + case model.Scrobble: + return s.Scrobble(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index abc5c4b6a..9b1827674 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,6 +4,7 @@ import ( "context" "path/filepath" "testing" + "time" "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" @@ -157,6 +158,13 @@ var ( testUsers = model.Users{adminUser, regularUser, thirdUser} ) +var ( + firstScrobble = model.Scrobble{MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)} + secondScrobble = model.Scrobble{MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC)} + thirdScrobble = model.Scrobble{MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC)} + scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} +) + func p(path string) string { return filepath.FromSlash(path) } @@ -304,6 +312,18 @@ var _ = BeforeSuite(func() { songComeTogether.Starred = true songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether + + scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository) + for _, s := range scrobbles { + _, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{ + "media_file_id": s.MediaFileID, + "user_id": s.UserID, + "submission_time": s.SubmissionTime.Unix(), + })) + if err != nil { + panic(err) + } + } }) func GetDBXBuilder() *dbx.DB { diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..2c6b8901f 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -5,7 +5,9 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -13,11 +15,70 @@ type scrobbleRepository struct { sqlRepository } +type dbScrobble struct { + dbMediaFile + RowId int64 `structs:"row_id" json:"rowId"` + SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` +} + +func (m dbScrobble) toScrobble() model.Scrobble { + return model.Scrobble{ + MediaFile: *m.MediaFile, + RowId: m.RowId, + SubmissionTime: time.Unix(m.SubmissionTime, 0), + } +} + +type dbScrobbles []dbScrobble + +func (m dbScrobbles) toModels() model.Scrobbles { + return slice.Map(m, func(db dbScrobble) model.Scrobble { + return db.toScrobble() + }) +} + +func fromTs(_ string, value any) Sqlizer { + return GtOrEq{"scrobbles.submission_time": value} +} + +func toTs(_ string, value any) Sqlizer { + return LtOrEq{"scrobbles.submission_time": value} +} + +func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuilder { + user := loggedUser(r.ctx) + + return r.newSelect(options...). + Columns("scrobbles.ROWID row_id", "submission_time", "media_file.*", "library.path as library_path", "library.name as library_name"). + Join("media_file on media_file.id = media_file_id"). + LeftJoin("library on media_file.library_id = library.id"). + LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = '"+user.ID+"')"). + Columns( + "coalesce(starred, 0) as starred", + "coalesce(rating, 0) as rating", + "starred_at", + "play_date", + "coalesce(play_count, 0) as play_count", + ). + Where(Eq{"scrobbles.user_id": user.ID}) +} + func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { r := &scrobbleRepository{} r.ctx = ctx r.db = db r.tableName = "scrobbles" + r.registerModel(&model.Scrobble{}, map[string]filterFunc{ + "from": fromTs, + "to": toTs, + "title": fullTextFilter("media_file"), + }) + r.setSortMappings(map[string]string{ + "submission_time": "submission_time", + }) return r } @@ -32,3 +93,62 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t _, err := r.executeSQL(insert) return err } + +func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { + user := loggedUser(r.ctx) + + sel := r.newSelect(). + Columns("count(*) count"). + Join("media_file on media_file.id = media_file_id"). + Where(Eq{"user_id": user.ID}) + + sel = r.applyFilters(sel, options...) + + var res struct{ Count int64 } + err := r.queryOne(sel, &res) + return res.Count, err +} + +func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { + sel := r.baseQuery().Where(Eq{"row_id": id}) + res := dbScrobble{} + err := r.queryOne(sel, &res) + if err != nil { + return nil, err + } + model := res.toScrobble() + return &model, nil +} + +func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + sel := r.baseQuery(options...) + var scrobbles dbScrobbles + err := r.queryAll(sel, &scrobbles) + if err != nil { + return nil, err + } + return scrobbles.toModels(), nil +} + +func (r *scrobbleRepository) Read(id string) (any, error) { + return r.Get(id) +} + +func (r *scrobbleRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *scrobbleRepository) EntityName() string { + return "scrobble" +} + +func (r *scrobbleRepository) NewInstance() any { + return &model.Scrobble{} +} + +var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) +var _ model.ResourceRepository = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index d43848d03..4ccf7df83 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -15,32 +16,33 @@ import ( var _ = Describe("ScrobbleRepository", func() { var repo model.ScrobbleRepository - var rawRepo sqlRepository var ctx context.Context - var fileID string - var userID string - - BeforeEach(func() { - fileID = id.NewRandom() - userID = id.NewRandom() - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) - db := GetDBXBuilder() - repo = NewScrobbleRepository(ctx, db) - - rawRepo = sqlRepository{ - ctx: ctx, - tableName: "scrobbles", - db: db, - } - }) - - AfterEach(func() { - _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() - _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() - _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() - }) Describe("RecordScrobble", func() { + var fileID string + var userID string + var rawRepo sqlRepository + + BeforeEach(func() { + fileID = id.NewRandom() + userID = id.NewRandom() + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) + db := GetDBXBuilder() + repo = NewScrobbleRepository(ctx, db) + + rawRepo = sqlRepository{ + ctx: ctx, + tableName: "scrobbles", + db: db, + } + }) + + AfterEach(func() { + _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() + _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() + _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() + }) + It("records a scrobble event", func() { submissionTime := time.Now().UTC() @@ -81,4 +83,137 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix())) }) }) + + Context("admin user (id userid)", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("1") + Expect(err).To(BeNil()) + scrobble.MediaFile.CreatedAt = time.Time{} + Expect(scrobble.MediaFile).To(Equal(songDayInALife)) + Expect(scrobble.SubmissionTime).To(BeTemporally("==", time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + + It("does not return a scrobble that exists for another user", func() { + scrobble, err := repo.Get("2") + Expect(scrobble).To(BeNil()) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + scrobble, err := repo.Get("444") + Expect(scrobble).To(BeNil()) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(2)) + + for idx := range scrobbles { + scrobbles[idx].MediaFile.CreatedAt = time.Time{} + } + + Expect(scrobbles[1].MediaFile).To(Equal(songDayInALife)) + Expect(scrobbles[0].MediaFile).To(Equal(songComeTogether)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + scrobbles[0].MediaFile.CreatedAt = time.Time{} + Expect(scrobbles[0].MediaFile).To(Equal(songComeTogether)) + + }) + }) + }) + + Context("non-admin user", func() { + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), regularUser) + repo = NewScrobbleRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("Returns the number of scrobbles in the DB for admin user", func() { + Expect(repo.CountAll()).To(Equal(int64(1))) + }) + + It("returns scrobbles in a range", func() { + Expect(repo.CountAll(model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) + }) + }) + + Describe("Get", func() { + It("returns an existing scrobble for the user", func() { + scrobble, err := repo.Get("2") + Expect(err).To(BeNil()) + scrobble.MediaFile.CreatedAt = time.Time{} + Expect(scrobble.MediaFile).To(Equal(songRadioactivity)) + Expect(scrobble.SubmissionTime).To(BeTemporally("==", time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC))) + }) + + It("does not return a scrobble that exists for another user", func() { + scrobble, err := repo.Get("1") + Expect(scrobble).To(BeNil()) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + scrobble, err := repo.Get("444") + Expect(scrobble).To(BeNil()) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all scrobbles in reverse order", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Sort: "submission_time", + Order: "DESC", + }) + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + scrobbles[0].MediaFile.CreatedAt = time.Time{} + Expect(scrobbles[0].MediaFile).To(Equal(songRadioactivity)) + }) + + It("returns scrobbles in a range", func() { + scrobbles, err := repo.GetAll(model.QueryOptions{ + Filters: squirrel.GtOrEq{"submission_time": 1}}) + + Expect(err).To(BeNil()) + Expect(scrobbles).To(HaveLen(1)) + + scrobbles[0].MediaFile.CreatedAt = time.Time{} + Expect(scrobbles[0].MediaFile).To(Equal(songRadioactivity)) + }) + }) + }) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..f5f532c1b 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -73,6 +73,7 @@ func (api *Router) routes() http.Handler { api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) api.R(r, "/tag", model.Tag{}, true) + api.R(r, "/scrobble", model.Scrobble{}, true) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) } diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 34561c257..bdca6c290 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -13,6 +13,24 @@ type MockScrobbleRepo struct { ctx context.Context } +func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { + for _, scrobble := range m.RecordedScrobbles { + if scrobble.ID == id { + return &scrobble, nil + } + } + + return nil, model.ErrNotFound +} + +func (m *MockScrobbleRepo) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + return m.RecordedScrobbles, nil +} + +func (m *MockScrobbleRepo) CountAll(options ...model.QueryOptions) (int64, error) { + return int64(len(m.RecordedScrobbles)), nil +} + func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { user, _ := request.UserFrom(m.ctx) m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ @@ -22,3 +40,5 @@ func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Tim }) return nil } + +var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) From 1ad8b59dbc980148d3ae630ced0cdf81ed4a1133 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:05:13 -0700 Subject: [PATCH 02/11] feat: add scrobble retrieval api --- model/scrobble.go | 8 ++--- persistence/scrobble_repository.go | 46 +++++++------------------ persistence/scrobble_repository_test.go | 40 +++++++++++---------- tests/mock_scrobble_repo.go | 3 +- 4 files changed, 39 insertions(+), 58 deletions(-) diff --git a/model/scrobble.go b/model/scrobble.go index 45b219292..175d4c945 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,12 +3,10 @@ package model import "time" type Scrobble struct { - MediaFileID string `json:"-"` - UserID string `json:"-"` - + ID int64 `structs:"id" json:"id"` + MediaFileID string `structs:"media_file_id" json:"mediaFileId"` + UserID string `json:"-"` SubmissionTime time.Time `structs:"submission_time" json:"submissionTime"` - RowId int64 `structs:"row_id" json:"rowId"` - MediaFile } type ScrobbleRepository interface { diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 2c6b8901f..7238901cb 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -16,15 +16,15 @@ type scrobbleRepository struct { } type dbScrobble struct { - dbMediaFile - RowId int64 `structs:"row_id" json:"rowId"` - SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` + MediaFileID string `structs:"media_file_id" json:"mediaFileId"` + RowId int64 `structs:"row_id" json:"rowId"` + SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` } func (m dbScrobble) toScrobble() model.Scrobble { return model.Scrobble{ - MediaFile: *m.MediaFile, - RowId: m.RowId, + MediaFileID: m.MediaFileID, + ID: m.RowId, SubmissionTime: time.Unix(m.SubmissionTime, 0), } } @@ -49,20 +49,7 @@ func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuil user := loggedUser(r.ctx) return r.newSelect(options...). - Columns("scrobbles.ROWID row_id", "submission_time", "media_file.*", "library.path as library_path", "library.name as library_name"). - Join("media_file on media_file.id = media_file_id"). - LeftJoin("library on media_file.library_id = library.id"). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = '"+user.ID+"')"). - Columns( - "coalesce(starred, 0) as starred", - "coalesce(rating, 0) as rating", - "starred_at", - "play_date", - "coalesce(play_count, 0) as play_count", - ). + Columns("scrobbles.ROWID row_id", "media_file_id", "submission_time"). Where(Eq{"scrobbles.user_id": user.ID}) } @@ -72,9 +59,8 @@ func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRe r.db = db r.tableName = "scrobbles" r.registerModel(&model.Scrobble{}, map[string]filterFunc{ - "from": fromTs, - "to": toTs, - "title": fullTextFilter("media_file"), + "from": fromTs, + "to": toTs, }) r.setSortMappings(map[string]string{ "submission_time": "submission_time", @@ -95,17 +81,9 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t } func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { - user := loggedUser(r.ctx) - - sel := r.newSelect(). - Columns("count(*) count"). - Join("media_file on media_file.id = media_file_id"). - Where(Eq{"user_id": user.ID}) - - sel = r.applyFilters(sel, options...) - + count := r.baseQuery(options...).RemoveColumns().Column("COUNT() as count").RemoveOffset().RemoveLimit().OrderBy("scrobbles.ROWID") var res struct{ Count int64 } - err := r.queryOne(sel, &res) + err := r.queryOne(count, &res) return res.Count, err } @@ -115,13 +93,13 @@ func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { sel := r.baseQuery().Where(Eq{"row_id": id}) - res := dbScrobble{} + var res dbScrobble err := r.queryOne(sel, &res) if err != nil { return nil, err } model := res.toScrobble() - return &model, nil + return &model, err } func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index 4ccf7df83..bf95ab3a2 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -104,9 +104,10 @@ var _ = Describe("ScrobbleRepository", func() { It("returns an existing scrobble for the user", func() { scrobble, err := repo.Get("1") Expect(err).To(BeNil()) - scrobble.MediaFile.CreatedAt = time.Time{} - Expect(scrobble.MediaFile).To(Equal(songDayInALife)) - Expect(scrobble.SubmissionTime).To(BeTemporally("==", time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))) + Expect(scrobble.ID).To(Equal(int64(1))) + Expect(scrobble.MediaFileID).To(Equal("1001")) + Expect(scrobble.SubmissionTime).To(BeTemporally("==", firstScrobble.SubmissionTime)) + }) It("does not return a scrobble that exists for another user", func() { @@ -131,12 +132,13 @@ var _ = Describe("ScrobbleRepository", func() { Expect(err).To(BeNil()) Expect(scrobbles).To(HaveLen(2)) - for idx := range scrobbles { - scrobbles[idx].MediaFile.CreatedAt = time.Time{} - } + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", thirdScrobble.SubmissionTime)) - Expect(scrobbles[1].MediaFile).To(Equal(songDayInALife)) - Expect(scrobbles[0].MediaFile).To(Equal(songComeTogether)) + Expect(scrobbles[1].ID).To(Equal(int64(1))) + Expect(scrobbles[1].MediaFileID).To(Equal("1001")) + Expect(scrobbles[1].SubmissionTime).To(BeTemporally("==", firstScrobble.SubmissionTime)) }) It("returns scrobbles in a range", func() { @@ -146,9 +148,9 @@ var _ = Describe("ScrobbleRepository", func() { Expect(err).To(BeNil()) Expect(scrobbles).To(HaveLen(1)) - scrobbles[0].MediaFile.CreatedAt = time.Time{} - Expect(scrobbles[0].MediaFile).To(Equal(songComeTogether)) - + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", thirdScrobble.SubmissionTime)) }) }) }) @@ -173,9 +175,9 @@ var _ = Describe("ScrobbleRepository", func() { It("returns an existing scrobble for the user", func() { scrobble, err := repo.Get("2") Expect(err).To(BeNil()) - scrobble.MediaFile.CreatedAt = time.Time{} - Expect(scrobble.MediaFile).To(Equal(songRadioactivity)) - Expect(scrobble.SubmissionTime).To(BeTemporally("==", time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC))) + Expect(scrobble.ID).To(Equal(int64(2))) + Expect(scrobble.MediaFileID).To(Equal("1003")) + Expect(scrobble.SubmissionTime).To(BeTemporally("==", secondScrobble.SubmissionTime)) }) It("does not return a scrobble that exists for another user", func() { @@ -200,8 +202,9 @@ var _ = Describe("ScrobbleRepository", func() { Expect(err).To(BeNil()) Expect(scrobbles).To(HaveLen(1)) - scrobbles[0].MediaFile.CreatedAt = time.Time{} - Expect(scrobbles[0].MediaFile).To(Equal(songRadioactivity)) + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", secondScrobble.SubmissionTime)) }) It("returns scrobbles in a range", func() { @@ -211,8 +214,9 @@ var _ = Describe("ScrobbleRepository", func() { Expect(err).To(BeNil()) Expect(scrobbles).To(HaveLen(1)) - scrobbles[0].MediaFile.CreatedAt = time.Time{} - Expect(scrobbles[0].MediaFile).To(Equal(songRadioactivity)) + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", secondScrobble.SubmissionTime)) }) }) }) diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index bdca6c290..30fe4ee98 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -2,6 +2,7 @@ package tests import ( "context" + "strconv" "time" "github.com/navidrome/navidrome/model" @@ -15,7 +16,7 @@ type MockScrobbleRepo struct { func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { for _, scrobble := range m.RecordedScrobbles { - if scrobble.ID == id { + if strconv.FormatInt(scrobble.ID, 10) == id { return &scrobble, nil } } From 8d39d7797a8322f317569b4061bfeaf81d8902cb Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:23:14 -0700 Subject: [PATCH 03/11] address feedback (1) --- persistence/scrobble_repository.go | 15 +++++++++------ tests/mock_scrobble_repo.go | 6 +++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 7238901cb..a1949e66f 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -16,9 +16,9 @@ type scrobbleRepository struct { } type dbScrobble struct { - MediaFileID string `structs:"media_file_id" json:"mediaFileId"` - RowId int64 `structs:"row_id" json:"rowId"` - SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` + MediaFileID string `db:"media_file_id"` + RowId int64 `db:"row_id"` + SubmissionTime int64 `db:"submission_time"` } func (m dbScrobble) toScrobble() model.Scrobble { @@ -81,7 +81,10 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t } func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { - count := r.baseQuery(options...).RemoveColumns().Column("COUNT() as count").RemoveOffset().RemoveLimit().OrderBy("scrobbles.ROWID") + userID := loggedUser(r.ctx).ID + count := r.newSelect().Column("COUNT(*) as count").Where(Eq{"user_id": userID}) + // We do this instead of newSeelct, because we do not want to apply limit/offset/order + count = r.applyFilters(count, options...) var res struct{ Count int64 } err := r.queryOne(count, &res) return res.Count, err @@ -98,8 +101,8 @@ func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { if err != nil { return nil, err } - model := res.toScrobble() - return &model, err + asModel := res.toScrobble() + return &asModel, err } func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 30fe4ee98..44d9728d8 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -15,9 +15,9 @@ type MockScrobbleRepo struct { } func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { - for _, scrobble := range m.RecordedScrobbles { - if strconv.FormatInt(scrobble.ID, 10) == id { - return &scrobble, nil + for idx := range m.RecordedScrobbles { + if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { + return &m.RecordedScrobbles[idx], nil } } From 429b485026422e30d4ac36e625b74ba84924d99e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:27:53 -0700 Subject: [PATCH 04/11] fix spelling --- persistence/scrobble_repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index a1949e66f..4f4314964 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -83,7 +83,7 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { userID := loggedUser(r.ctx).ID count := r.newSelect().Column("COUNT(*) as count").Where(Eq{"user_id": userID}) - // We do this instead of newSeelct, because we do not want to apply limit/offset/order + // We do this instead of newSelect, because we do not want to apply limit/offset/order count = r.applyFilters(count, options...) var res struct{ Count int64 } err := r.queryOne(count, &res) From d1f16e0bb06591079ec4b40aa159f0512f0fc1d0 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:30:13 -0700 Subject: [PATCH 05/11] be explicit about get --- persistence/scrobble_repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 4f4314964..481defe6e 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -95,7 +95,7 @@ func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) } func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { - sel := r.baseQuery().Where(Eq{"row_id": id}) + sel := r.baseQuery().Where(Eq{"scrobbles.ROWID": id}) var res dbScrobble err := r.queryOne(sel, &res) if err != nil { From c8fc6e6e0ae16674aa34b2a879ac69ca8e3370f0 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:33:15 -0700 Subject: [PATCH 06/11] add primary key field, update index, remove rowid references --- ...ary_key_and_update_index_for_scrobbles.sql | 39 +++++++++++++++++++ persistence/persistence_suite_test.go | 6 +-- persistence/scrobble_repository.go | 16 +++----- server/nativeapi/native_api.go | 4 +- 4 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql diff --git a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql new file mode 100644 index 000000000..220d7cf75 --- /dev/null +++ b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql @@ -0,0 +1,39 @@ +-- +goose Up +CREATE TABLE scrobbles_tmp( + id INTEGER PRIMARY KEY, + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_date; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time); + + +-- +goose Down +CREATE TABLE scrobbles_tmp( + media_file_id VARCHAR(255) NOT NULL + REFERENCES media_file(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + user_id VARCHAR(255) NOT NULL + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + submission_time INTEGER NOT NULL +); +INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles; + +DROP INDEX scrobbles_user_time; +DROP TABLE scrobbles; +ALTER TABLE scrobbles_tmp RENAME TO scrobbles; +CREATE INDEX scrobbles_date ON scrobbles(submission_time); \ No newline at end of file diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 9b1827674..7f7fed809 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -159,9 +159,9 @@ var ( ) var ( - firstScrobble = model.Scrobble{MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)} - secondScrobble = model.Scrobble{MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC)} - thirdScrobble = model.Scrobble{MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC)} + firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)} + secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC)} + thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC)} scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} ) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 481defe6e..18715cdb4 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -16,15 +16,15 @@ type scrobbleRepository struct { } type dbScrobble struct { + ID int64 `db:"id"` MediaFileID string `db:"media_file_id"` - RowId int64 `db:"row_id"` SubmissionTime int64 `db:"submission_time"` } func (m dbScrobble) toScrobble() model.Scrobble { return model.Scrobble{ MediaFileID: m.MediaFileID, - ID: m.RowId, + ID: m.ID, SubmissionTime: time.Unix(m.SubmissionTime, 0), } } @@ -49,7 +49,7 @@ func (r *scrobbleRepository) baseQuery(options ...model.QueryOptions) SelectBuil user := loggedUser(r.ctx) return r.newSelect(options...). - Columns("scrobbles.ROWID row_id", "media_file_id", "submission_time"). + Columns("id", "media_file_id", "submission_time"). Where(Eq{"scrobbles.user_id": user.ID}) } @@ -81,13 +81,7 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t } func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { - userID := loggedUser(r.ctx).ID - count := r.newSelect().Column("COUNT(*) as count").Where(Eq{"user_id": userID}) - // We do this instead of newSelect, because we do not want to apply limit/offset/order - count = r.applyFilters(count, options...) - var res struct{ Count int64 } - err := r.queryOne(count, &res) - return res.Count, err + return r.count(r.baseQuery(), options...) } func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) { @@ -95,7 +89,7 @@ func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) } func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { - sel := r.baseQuery().Where(Eq{"scrobbles.ROWID": id}) + sel := r.baseQuery().Where(Eq{"id": id}) var res dbScrobble err := r.queryOne(sel, &res) if err != nil { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f5f532c1b..5a7023eb6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -72,8 +72,8 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) - api.R(r, "/tag", model.Tag{}, true) - api.R(r, "/scrobble", model.Scrobble{}, true) + api.R(r, "/tag", model.Tag{}, false) + api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) } From aae50b19792d9344f7e6f397ee6e5248e03c0287 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:09:06 -0700 Subject: [PATCH 07/11] use unix timestamp for input and output --- core/scrobbler/play_tracker_test.go | 2 +- model/scrobble.go | 8 +++--- persistence/persistence_suite_test.go | 8 +++--- persistence/scrobble_repository.go | 38 +++---------------------- persistence/scrobble_repository_test.go | 26 +++++++---------- tests/mock_scrobble_repo.go | 2 +- 6 files changed, 25 insertions(+), 59 deletions(-) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 74b4be893..831b0ce0d 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -290,7 +290,7 @@ var _ = Describe("PlayTracker", func() { Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) - Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts)) + Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix())) }) It("does not record scrobble when history is disabled", func() { diff --git a/model/scrobble.go b/model/scrobble.go index 175d4c945..a8022fc16 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,10 +3,10 @@ package model import "time" type Scrobble struct { - ID int64 `structs:"id" json:"id"` - MediaFileID string `structs:"media_file_id" json:"mediaFileId"` - UserID string `json:"-"` - SubmissionTime time.Time `structs:"submission_time" json:"submissionTime"` + ID int64 `structs:"id" json:"id"` + MediaFileID string `structs:"media_file_id" json:"mediaFileId"` + UserID string `json:"-"` + SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` } type ScrobbleRepository interface { diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 7f7fed809..4f2fd7fe2 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -159,9 +159,9 @@ var ( ) var ( - firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)} - secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC)} - thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC)} + firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()} + secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()} + thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()} scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} ) @@ -318,7 +318,7 @@ var _ = BeforeSuite(func() { _, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{ "media_file_id": s.MediaFileID, "user_id": s.UserID, - "submission_time": s.SubmissionTime.Unix(), + "submission_time": s.SubmissionTime, })) if err != nil { panic(err) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 18715cdb4..7cc60ae23 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -7,7 +7,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -15,28 +14,6 @@ type scrobbleRepository struct { sqlRepository } -type dbScrobble struct { - ID int64 `db:"id"` - MediaFileID string `db:"media_file_id"` - SubmissionTime int64 `db:"submission_time"` -} - -func (m dbScrobble) toScrobble() model.Scrobble { - return model.Scrobble{ - MediaFileID: m.MediaFileID, - ID: m.ID, - SubmissionTime: time.Unix(m.SubmissionTime, 0), - } -} - -type dbScrobbles []dbScrobble - -func (m dbScrobbles) toModels() model.Scrobbles { - return slice.Map(m, func(db dbScrobble) model.Scrobble { - return db.toScrobble() - }) -} - func fromTs(_ string, value any) Sqlizer { return GtOrEq{"scrobbles.submission_time": value} } @@ -90,23 +67,16 @@ func (r *scrobbleRepository) Count(options ...rest.QueryOptions) (int64, error) func (r *scrobbleRepository) Get(id string) (*model.Scrobble, error) { sel := r.baseQuery().Where(Eq{"id": id}) - var res dbScrobble + var res model.Scrobble err := r.queryOne(sel, &res) - if err != nil { - return nil, err - } - asModel := res.toScrobble() - return &asModel, err + return &res, err } func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { sel := r.baseQuery(options...) - var scrobbles dbScrobbles + var scrobbles model.Scrobbles err := r.queryAll(sel, &scrobbles) - if err != nil { - return nil, err - } - return scrobbles.toModels(), nil + return scrobbles, err } func (r *scrobbleRepository) Read(id string) (any, error) { diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go index bf95ab3a2..e9103b127 100644 --- a/persistence/scrobble_repository_test.go +++ b/persistence/scrobble_repository_test.go @@ -106,19 +106,17 @@ var _ = Describe("ScrobbleRepository", func() { Expect(err).To(BeNil()) Expect(scrobble.ID).To(Equal(int64(1))) Expect(scrobble.MediaFileID).To(Equal("1001")) - Expect(scrobble.SubmissionTime).To(BeTemporally("==", firstScrobble.SubmissionTime)) + Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) }) It("does not return a scrobble that exists for another user", func() { - scrobble, err := repo.Get("2") - Expect(scrobble).To(BeNil()) + _, err := repo.Get("2") Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not return a scrobble that does not exist", func() { - scrobble, err := repo.Get("444") - Expect(scrobble).To(BeNil()) + _, err := repo.Get("444") Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -134,11 +132,11 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobbles[0].ID).To(Equal(int64(3))) Expect(scrobbles[0].MediaFileID).To(Equal("1002")) - Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", thirdScrobble.SubmissionTime)) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) Expect(scrobbles[1].ID).To(Equal(int64(1))) Expect(scrobbles[1].MediaFileID).To(Equal("1001")) - Expect(scrobbles[1].SubmissionTime).To(BeTemporally("==", firstScrobble.SubmissionTime)) + Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) }) It("returns scrobbles in a range", func() { @@ -150,7 +148,7 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobbles[0].ID).To(Equal(int64(3))) Expect(scrobbles[0].MediaFileID).To(Equal("1002")) - Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", thirdScrobble.SubmissionTime)) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) }) }) }) @@ -177,18 +175,16 @@ var _ = Describe("ScrobbleRepository", func() { Expect(err).To(BeNil()) Expect(scrobble.ID).To(Equal(int64(2))) Expect(scrobble.MediaFileID).To(Equal("1003")) - Expect(scrobble.SubmissionTime).To(BeTemporally("==", secondScrobble.SubmissionTime)) + Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) }) It("does not return a scrobble that exists for another user", func() { - scrobble, err := repo.Get("1") - Expect(scrobble).To(BeNil()) + _, err := repo.Get("1") Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not return a scrobble that does not exist", func() { - scrobble, err := repo.Get("444") - Expect(scrobble).To(BeNil()) + _, err := repo.Get("444") Expect(err).To(MatchError(model.ErrNotFound)) }) }) @@ -204,7 +200,7 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobbles[0].ID).To(Equal(int64(2))) Expect(scrobbles[0].MediaFileID).To(Equal("1003")) - Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", secondScrobble.SubmissionTime)) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) }) It("returns scrobbles in a range", func() { @@ -216,7 +212,7 @@ var _ = Describe("ScrobbleRepository", func() { Expect(scrobbles[0].ID).To(Equal(int64(2))) Expect(scrobbles[0].MediaFileID).To(Equal("1003")) - Expect(scrobbles[0].SubmissionTime).To(BeTemporally("==", secondScrobble.SubmissionTime)) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) }) }) }) diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go index 44d9728d8..d6d88d221 100644 --- a/tests/mock_scrobble_repo.go +++ b/tests/mock_scrobble_repo.go @@ -37,7 +37,7 @@ func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Tim m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ MediaFileID: fileID, UserID: user.ID, - SubmissionTime: submissionTime, + SubmissionTime: submissionTime.Unix(), }) return nil } From 23d60337b8cdff5d05c856aa1099afc124144cf7 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:45:22 -0700 Subject: [PATCH 08/11] initial api, some testing --- plugins/host/scrobble_retriever.go | 38 ++++ plugins/host/scrobbleretriever_gen.go | 181 +++++++++++++++++ plugins/host_scrobbleretriever.go | 122 ++++++++++++ plugins/manager_loader.go | 8 + plugins/manifest-schema.json | 14 ++ plugins/manifest_gen.go | 9 + plugins/pdk/go/host/doc.go | 1 + .../pdk/go/host/nd_host_scrobbleretriever.go | 183 ++++++++++++++++++ .../go/host/nd_host_scrobbleretriever_stub.go | 77 ++++++++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../src/nd_host_scrobbleretriever.rs | 160 +++++++++++++++ .../testdata/test-scrobble-retriever/go.mod | 16 ++ .../testdata/test-scrobble-retriever/go.sum | 14 ++ .../testdata/test-scrobble-retriever/main.go | 42 ++++ .../test-scrobble-retriever/manifest.json | 14 ++ 15 files changed, 887 insertions(+) create mode 100644 plugins/host/scrobble_retriever.go create mode 100644 plugins/host/scrobbleretriever_gen.go create mode 100644 plugins/host_scrobbleretriever.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs create mode 100644 plugins/testdata/test-scrobble-retriever/go.mod create mode 100644 plugins/testdata/test-scrobble-retriever/go.sum create mode 100644 plugins/testdata/test-scrobble-retriever/main.go create mode 100644 plugins/testdata/test-scrobble-retriever/manifest.json diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go new file mode 100644 index 000000000..06abf59de --- /dev/null +++ b/plugins/host/scrobble_retriever.go @@ -0,0 +1,38 @@ +package host + +import "context" + +type ScrobbleList struct { + Scrobbles []ScrobbleRef `json:"scrobbles"` + NextTimestamp *int64 `json:"nextTimestamp,omitempty"` +} + +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + MaxItems int `json:"maxItems"` +} + +// ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. +// It will only provide the media_file ID and submission time, which can be combined with the MatcherService +// to fetch deduped tracks +// +//nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever +type ScrobbleRetrieverService interface { + // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user + //nd:hostfunc + GetFirstTimestamp(ctx context.Context, username string) (*int64, error) + + // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user + //nd:hostfunc + GetLastTimestamp(ctx context.Context, username string) (*int64, error) + + //nd:hostfunc + GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (*ScrobbleList, error) +} diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go new file mode 100644 index 000000000..f3f3c3906 --- /dev/null +++ b/plugins/host/scrobbleretriever_gen.go @@ -0,0 +1,181 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetFirstTimestampResponse is the response type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesResponse struct { + Result *ScrobbleList `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { + return []extism.HostFunction{ + newScrobbleRetrieverGetLastTimestampHostFunction(service), + newScrobbleRetrieverGetFirstTimestampHostFunction(service), + newScrobbleRetrieverGetScrobblesHostFunction(service), + } +} + +func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getlasttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetLastTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetLastTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetLastTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getfirsttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetFirstTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetFirstTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetFirstTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobbles", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobblesRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetScrobbles(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobblesResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// scrobbleretrieverWriteResponse writes a JSON response to plugin memory. +func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// scrobbleretrieverWriteError writes an error response to plugin memory. +func scrobbleretrieverWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go new file mode 100644 index 000000000..54ae3456a --- /dev/null +++ b/plugins/host_scrobbleretriever.go @@ -0,0 +1,122 @@ +package plugins + +import ( + "context" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" +) + +type scrobbleRetrieverServiceImpl struct { + ds model.DataStore + users userAccess +} + +func newScrobbleRetreverService(ds model.DataStore, users userAccess) host.ScrobbleRetrieverService { + return &scrobbleRetrieverServiceImpl{ + ds: ds, + users: users, + } +} + +func (s *scrobbleRetrieverServiceImpl) getUserContext(ctx context.Context, username string) (context.Context, error) { + usr, err := s.users.resolve(ctx, s.ds, username) + if err != nil { + return nil, fmt.Errorf("scrobbleRetriever: %w", err) + } + + ctx = request.WithUser(ctx, *usr) + return ctx, nil +} + +func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, username string, order string) (*int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, err + } + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) + if err != nil { + return nil, err + } + + if len(scrobbles) == 0 { + return nil, nil + } + + return &scrobbles[0].SubmissionTime, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetFirstTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "ASC") +} + +func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "DESC") +} + +func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) (*host.ScrobbleList, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, err + } + + if options.MaxItems == 0 { + options.MaxItems = 5000 + } + + // Fetch one more item than requested. The last item is the next timestamp to fetch + options.MaxItems += 1 + + var filters squirrel.Sqlizer + if options.FromTimestamp != nil { + filters = squirrel.GtOrEq{"submission_time": *options.FromTimestamp} + } + + if options.ToTimestamp != nil { + filters = squirrel.And{filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp}} + } + + var order string + if options.ToTimestamp != nil && options.FromTimestamp == nil { + order = "DESC" + } else { + order = "ASC" + } + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{ + Max: options.MaxItems, + Filters: filters, + Sort: "submission_time", + Order: order, + }) + + if err != nil { + return nil, err + } + + var nextTimestamp *int64 + + if len(scrobbles) == options.MaxItems { + nextTimestamp = &scrobbles[options.MaxItems-1].SubmissionTime + } + + scrobbleRefs := make([]host.ScrobbleRef, options.MaxItems-1) + for idx := range scrobbleRefs { + scrobbleRefs[idx].ID = scrobbles[idx].ID + scrobbleRefs[idx].MediaFileID = scrobbles[idx].MediaFileID + scrobbleRefs[idx].SubmissionTime = scrobbles[idx].SubmissionTime + } + + response := host.ScrobbleList{ + Scrobbles: scrobbleRefs, + NextTimestamp: nextTimestamp, + } + + return &response, nil +} + +var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 757ededb5..256eb88d9 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -168,6 +168,14 @@ var hostServices = []hostServiceEntry{ return host.RegisterTaskHostFunctions(service), service, nil }, }, + { + name: "ScrobbleRetriever", + hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + service := newScrobbleRetreverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) + return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 29e5d1fc7..6bc8ca0e4 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -116,6 +116,9 @@ }, "matcher": { "$ref": "#/$defs/MatcherPermission" + }, + "scrobbleRetriever": { + "$ref": "#/$defs/ScrobbleRetrieverPermission" } } }, @@ -268,6 +271,17 @@ "description": "Explanation for why matcher access is needed" } } + }, + "ScrobbleRetrieverPermission": { + "type": "object", + "description": "Scrobble retriever permissions for retrieving scrobbles from users", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why scrobble retriever access is needed" + } + } } } } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 3599eafc4..b0ab58dae 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -187,6 +187,9 @@ type Permissions struct { // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + // ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever". + ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"` + // Subsonicapi corresponds to the JSON schema field "subsonicapi". Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` @@ -206,6 +209,12 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Scrobble retriever permissions for retrieving scrobbles from users +type ScrobbleRetrieverPermission struct { + // Explanation for why scrobble retriever access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // SubsonicAPI service permissions. Requires 'users' permission to be declared. type SubsonicAPIPermission struct { // Explanation for why SubsonicAPI access is needed diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index ff2c2a07f..edd52fdd8 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. + - ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users. - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. - Users: provides access to user information for plugins. diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go new file mode 100644 index 000000000..e1f179822 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -0,0 +1,183 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// ScrobbleList represents the ScrobbleList data structure. +type ScrobbleList struct { + Scrobbles []ScrobbleRef `json:"scrobbles"` + NextTimestamp *int64 `json:"nextTimestamp"` +} + +// ScrobbleOptions represents the ScrobbleOptions data structure. +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` + MaxItems int `json:"maxItems"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp +func scrobbleretriever_getlasttimestamp(uint64) uint64 + +// scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp +func scrobbleretriever_getfirsttimestamp(uint64) uint64 + +// scrobbleretriever_getscrobbles is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobbles +func scrobbleretriever_getscrobbles(uint64) uint64 + +type scrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobblesResponse struct { + Result *ScrobbleList `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetLastTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetLastTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetFirstTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getfirsttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetFirstTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. +func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobblesRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobbles(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobblesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go new file mode 100644 index 000000000..8450c171b --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -0,0 +1,77 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/stretchr/testify/mock" +) + +// ScrobbleList represents the ScrobbleList data structure. +type ScrobbleList struct { + Scrobbles []ScrobbleRef `json:"scrobbles"` + NextTimestamp *int64 `json:"nextTimestamp"` +} + +// ScrobbleOptions represents the ScrobbleOptions data structure. +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` + MaxItems int `json:"maxItems"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// mockScrobbleRetrieverService is the mock implementation for testing. +type mockScrobbleRetrieverService struct { + mock.Mock +} + +// ScrobbleRetrieverMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) +var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} + +// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. +func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { + args := m.Called(username) + return args.Get(0).(*int64), args.Error(1) +} + +// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetLastTimestamp(username) +} + +// GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. +func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { + args := m.Called(username) + return args.Get(0).(*int64), args.Error(1) +} + +// ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetFirstTimestamp(username) +} + +// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. +func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { + args := m.Called(username, options) + return args.Get(0).(*ScrobbleList), args.Error(1) +} + +// ScrobbleRetrieverGetScrobbles delegates to the mock instance. +func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { + return ScrobbleRetrieverMock.GetScrobbles(username, options) +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index cc1fdc190..03a374930 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users. //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. //! - [`users`] - provides access to user information for plugins. @@ -101,6 +102,13 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } +#[doc(hidden)] +mod nd_host_scrobbleretriever; +/// allows a plugin to retrieve scrobbles for one or more authorized users. +pub mod scrobbleretriever { + pub use super::nd_host_scrobbleretriever::*; +} + #[doc(hidden)] mod nd_host_subsonicapi; /// provides access to Navidrome's Subsonic API from plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs new file mode 100644 index 000000000..35212aeab --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -0,0 +1,160 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleList { + pub scrobbles: Vec, + #[serde(default)] + pub next_timestamp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, + pub max_items: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleRef { + pub id: i64, + pub media_file_id: String, + pub submission_time: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesRequest { + username: String, + options: ScrobbleOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; + fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; + fn scrobbleretriever_getscrobbles(input: Json) -> Json; +} + +/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_last_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_first_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getfirsttimestamp(Json(ScrobbleRetrieverGetFirstTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// Calls the scrobbleretriever_getscrobbles host function. +/// +/// # Arguments +/// * `username` - String parameter. +/// * `options` - ScrobbleOptions parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod new file mode 100644 index 000000000..6265288f9 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/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-scrobble-retriever/go.sum b/plugins/testdata/test-scrobble-retriever/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/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-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go new file mode 100644 index 000000000..21102bf94 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +func main() { + +} + +type TestScrobbleTimestampOutput struct { + Timestamp *int64 `json:"timestamp"` +} + +//go:wasmexport call_get_first_timestamp +func callGetFirstTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetFirstTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +//go:wasmexport call_get_last_timestamp +func callGetLastTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetLastTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} diff --git a/plugins/testdata/test-scrobble-retriever/manifest.json b/plugins/testdata/test-scrobble-retriever/manifest.json new file mode 100644 index 000000000..a60203704 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Scrobble Retriever", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for scrobble retriever integration settings", + "permissions": { + "scrobbleRetriever": { + "reason": "For testing scrobble retriever operations" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +} From 6b72ce3c0dcc2a9675bae0550a41361b607a384d Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:04:26 -0700 Subject: [PATCH 09/11] add tests, add count retrieval --- plugins/host/scrobble_retriever.go | 8 + plugins/host/scrobbleretriever_gen.go | 139 ++++++--- plugins/host_scrobbleretriever.go | 43 ++- plugins/host_scrobbleretriever_test.go | 290 ++++++++++++++++++ .../pdk/go/host/nd_host_scrobbleretriever.go | 146 ++++++--- .../go/host/nd_host_scrobbleretriever_stub.go | 41 ++- .../src/nd_host_scrobbleretriever.rs | 112 +++++-- .../testdata/test-scrobble-retriever/go.mod | 2 +- .../testdata/test-scrobble-retriever/main.go | 64 ++++ 9 files changed, 705 insertions(+), 140 deletions(-) create mode 100644 plugins/host_scrobbleretriever_test.go diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go index 06abf59de..569dc8641 100644 --- a/plugins/host/scrobble_retriever.go +++ b/plugins/host/scrobble_retriever.go @@ -19,6 +19,11 @@ type ScrobbleOptions struct { MaxItems int `json:"maxItems"` } +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + // ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. // It will only provide the media_file ID and submission time, which can be combined with the MatcherService // to fetch deduped tracks @@ -35,4 +40,7 @@ type ScrobbleRetrieverService interface { //nd:hostfunc GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (*ScrobbleList, error) + + //nd:hostfunc + GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) } diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go index f3f3c3906..411826f76 100644 --- a/plugins/host/scrobbleretriever_gen.go +++ b/plugins/host/scrobbleretriever_gen.go @@ -9,17 +9,6 @@ import ( extism "github.com/extism/go-sdk" ) -// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. -type ScrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. -type ScrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - // ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. type ScrobbleRetrieverGetFirstTimestampRequest struct { Username string `json:"username"` @@ -31,6 +20,17 @@ type ScrobbleRetrieverGetFirstTimestampResponse struct { Error string `json:"error,omitempty"` } +// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + // ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. type ScrobbleRetrieverGetScrobblesRequest struct { Username string `json:"username"` @@ -43,50 +43,29 @@ type ScrobbleRetrieverGetScrobblesResponse struct { Error string `json:"error,omitempty"` } +// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobbleCountResponse is the response type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + // RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. // The returned host functions should be added to the plugin's configuration. func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { return []extism.HostFunction{ - newScrobbleRetrieverGetLastTimestampHostFunction(service), newScrobbleRetrieverGetFirstTimestampHostFunction(service), + newScrobbleRetrieverGetLastTimestampHostFunction(service), newScrobbleRetrieverGetScrobblesHostFunction(service), + newScrobbleRetrieverGetScrobbleCountHostFunction(service), } } -func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getlasttimestamp", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetLastTimestampRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetLastTimestamp(ctx, req.Username) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetLastTimestampResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { return extism.NewHostFunctionWithStack( "scrobbleretriever_getfirsttimestamp", @@ -121,6 +100,40 @@ func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetriever ) } +func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getlasttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetLastTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetLastTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetLastTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { return extism.NewHostFunctionWithStack( "scrobbleretriever_getscrobbles", @@ -155,6 +168,40 @@ func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverServi ) } +func newScrobbleRetrieverGetScrobbleCountHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobblecount", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobbleCountRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetScrobbleCount(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobbleCountResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + // scrobbleretrieverWriteResponse writes a JSON response to plugin memory. func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { respBytes, err := json.Marshal(resp) diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go index 54ae3456a..db2620bca 100644 --- a/plugins/host_scrobbleretriever.go +++ b/plugins/host_scrobbleretriever.go @@ -64,20 +64,20 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam return nil, err } - if options.MaxItems == 0 { + if options.MaxItems < 1 || options.MaxItems > 5000 { options.MaxItems = 5000 } // Fetch one more item than requested. The last item is the next timestamp to fetch options.MaxItems += 1 - var filters squirrel.Sqlizer + var filters squirrel.And if options.FromTimestamp != nil { - filters = squirrel.GtOrEq{"submission_time": *options.FromTimestamp} + filters = append(filters, squirrel.GtOrEq{"submission_time": *options.FromTimestamp}) } if options.ToTimestamp != nil { - filters = squirrel.And{filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp}} + filters = append(filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp}) } var order string @@ -99,13 +99,18 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam } var nextTimestamp *int64 + var targetLen int if len(scrobbles) == options.MaxItems { nextTimestamp = &scrobbles[options.MaxItems-1].SubmissionTime + targetLen = options.MaxItems - 1 + } else { + targetLen = len(scrobbles) } - scrobbleRefs := make([]host.ScrobbleRef, options.MaxItems-1) - for idx := range scrobbleRefs { + scrobbleRefs := make([]host.ScrobbleRef, targetLen) + + for idx := range targetLen { scrobbleRefs[idx].ID = scrobbles[idx].ID scrobbleRefs[idx].MediaFileID = scrobbles[idx].MediaFileID scrobbleRefs[idx].SubmissionTime = scrobbles[idx].SubmissionTime @@ -119,4 +124,30 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam return &response, nil } +func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return 0, err + } + + var filters squirrel.And + if options.FromTimestamp != nil { + filters = append(filters, squirrel.GtOrEq{"submission_time": *options.FromTimestamp}) + } + + if options.ToTimestamp != nil { + filters = append(filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp}) + } + + count, err := s.ds.Scrobble(ctx).CountAll(model.QueryOptions{ + Filters: filters, + }) + + if err != nil { + return 0, err + } + + return count, nil +} + var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go new file mode 100644 index 000000000..8175c7d06 --- /dev/null +++ b/plugins/host_scrobbleretriever_test.go @@ -0,0 +1,290 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() { + var ( + manager *Manager + tmpDir string + dataStore *tests.MockDataStore + ) + + BeforeAll(func() { + ctx := GinkgoT().Context() + + var err error + tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*") + Expect(err).ToNot(HaveOccurred()) + + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + userRepo := dataStore.User(ctx) + // Add test users + _ = userRepo.Put(&model.User{ + ID: "user1", + UserName: "testuser", + IsAdmin: false, + }) + _ = userRepo.Put(&model.User{ + ID: "admin1", + UserName: "adminuser", + IsAdmin: true, + }) + + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "1", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "2", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "3", LibraryID: 1}) + Expect(err).To(BeNil()) + + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + err = scrobbleRepo.RecordScrobble("1", time.Unix(0, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("2", time.Unix(1, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("3", time.Unix(2, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("1", time.Unix(2, 0)) + Expect(err).To(BeNil()) + + // Create and configure manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + } + router := &fakeSubsonicRouter{} + manager.SetSubsonicRouter(router) + + // Pre-enable the plugin in the mock repo so it loads on startup + // Compute SHA256 of the plugin file to match what syncPlugins will compute + pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + dataStore.MockedPlugin = tests.CreateMockPluginRepo() + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + enabledPlugin := model.Plugin{ + ID: "test-scrobble-retriever", + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + Users: `["user1","admin1"]`, + } + mockPluginRepo.SetData(model.Plugins{enabledPlugin}) + + // Start the manager + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("no items", func() { + var plugin *plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + It("calls get first timestamp", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls get last timestamp", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls scrobbles", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte(`{"scrobbles":[],"nextTimestamp":null}`))) + }) + + It("calls get scrobble count", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + Expect(output).To(Equal([]byte("0"))) + }) + }) + + Describe("with items", func() { + var plugin *plugin + + p := func(val int64) *int64 { + return &val + } + + scrobbles := []host.ScrobbleRef{ + {ID: 1, MediaFileID: "1", SubmissionTime: 0}, + {ID: 2, MediaFileID: "2", SubmissionTime: 1}, + {ID: 3, MediaFileID: "3", SubmissionTime: 2}, + {ID: 4, MediaFileID: "1", SubmissionTime: 2}, + } + + scrobblesReversed := make([]host.ScrobbleRef, 4) + + BeforeAll(func() { + for idx := range scrobbles { + scrobblesReversed[3-idx] = scrobbles[idx] + } + }) + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + It("calls get first timestamp", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":0}"))) + }) + + It("calls get last timestamp", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":2}"))) + }) + + DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, timestamp *int64) { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_scrobbles", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + var scrobbleList host.ScrobbleList + Expect(json.Unmarshal(output, &scrobbleList)).To(Succeed()) + Expect(scrobbleList).To(Equal(host.ScrobbleList{ + Scrobbles: scrobbles, + NextTimestamp: timestamp, + })) + }, + Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil), + Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil), + Entry("calls subset of scrobbles in ascending order, next timestamp", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], p(2)), + Entry("calls subset of scrobbles in ascending order, with offset next timestamp", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], p(2)), + Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil), + Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2}`, scrobblesReversed, nil), + Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2, "maxItems": 3}`, scrobblesReversed[:3], p(0)), + Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2, "maxItems": 1}`, scrobblesReversed[:1], p(2)), + ) + + DescribeTable("GetScrobblesCount", func(params string, count int) { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + value, err := strconv.ParseInt(string(output), 10, 64) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(int64(count))) + }, + Entry("gets all scrobbles", `{"username":"adminuser"}`, 4), + Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2), + Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1), + Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1), + Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4), + ) + }) +}) diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go index e1f179822..c73ff5073 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -14,6 +14,12 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + // ScrobbleList represents the ScrobbleList data structure. type ScrobbleList struct { Scrobbles []ScrobbleRef `json:"scrobbles"` @@ -34,29 +40,25 @@ type ScrobbleRef struct { SubmissionTime int64 `json:"submissionTime"` } -// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp -func scrobbleretriever_getlasttimestamp(uint64) uint64 - // scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. // //go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp func scrobbleretriever_getfirsttimestamp(uint64) uint64 +// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp +func scrobbleretriever_getlasttimestamp(uint64) uint64 + // scrobbleretriever_getscrobbles is the host function provided by Navidrome. // //go:wasmimport extism:host/user scrobbleretriever_getscrobbles func scrobbleretriever_getscrobbles(uint64) uint64 -type scrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -type scrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} +// scrobbleretriever_getscrobblecount is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobblecount +func scrobbleretriever_getscrobblecount(uint64) uint64 type scrobbleRetrieverGetFirstTimestampRequest struct { Username string `json:"username"` @@ -67,6 +69,15 @@ type scrobbleRetrieverGetFirstTimestampResponse struct { Error string `json:"error,omitempty"` } +type scrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + type scrobbleRetrieverGetScrobblesRequest struct { Username string `json:"username"` Options ScrobbleOptions `json:"options"` @@ -77,39 +88,14 @@ type scrobbleRetrieverGetScrobblesResponse struct { Error string `json:"error,omitempty"` } -// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetLastTimestampRequest{ - Username: username, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() +type scrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} - // Call the host function - responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetLastTimestampResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil +type scrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` } // ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. @@ -147,6 +133,41 @@ func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { return response.Result, nil } +// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetLastTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetLastTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + // ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { // Marshal request to JSON @@ -181,3 +202,38 @@ func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*S return response.Result, nil } + +// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobbleCountRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobbleCountResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go index 8450c171b..12bc46887 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -12,6 +12,12 @@ import ( "github.com/stretchr/testify/mock" ) +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + // ScrobbleList represents the ScrobbleList data structure. type ScrobbleList struct { Scrobbles []ScrobbleRef `json:"scrobbles"` @@ -41,18 +47,6 @@ type mockScrobbleRetrieverService struct { // Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} -// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. -func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { - args := m.Called(username) - return args.Get(0).(*int64), args.Error(1) -} - -// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - return ScrobbleRetrieverMock.GetLastTimestamp(username) -} - // GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { args := m.Called(username) @@ -65,6 +59,18 @@ func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { return ScrobbleRetrieverMock.GetFirstTimestamp(username) } +// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. +func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { + args := m.Called(username) + return args.Get(0).(*int64), args.Error(1) +} + +// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetLastTimestamp(username) +} + // GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { args := m.Called(username, options) @@ -75,3 +81,14 @@ func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options Scr func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { return ScrobbleRetrieverMock.GetScrobbles(username, options) } + +// GetScrobbleCount is the mock method for ScrobbleRetrieverGetScrobbleCount. +func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + args := m.Called(username, options) + return args.Get(0).(int64), args.Error(1) +} + +// ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + return ScrobbleRetrieverMock.GetScrobbleCount(username, options) +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs index 35212aeab..c3062ab4d 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -6,6 +6,15 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleCountOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleList { @@ -34,13 +43,13 @@ pub struct ScrobbleRef { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampRequest { +struct ScrobbleRetrieverGetFirstTimestampRequest { username: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampResponse { +struct ScrobbleRetrieverGetFirstTimestampResponse { #[serde(default)] result: Option, #[serde(default)] @@ -49,13 +58,13 @@ struct ScrobbleRetrieverGetLastTimestampResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampRequest { +struct ScrobbleRetrieverGetLastTimestampRequest { username: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampResponse { +struct ScrobbleRetrieverGetLastTimestampResponse { #[serde(default)] result: Option, #[serde(default)] @@ -78,35 +87,28 @@ struct ScrobbleRetrieverGetScrobblesResponse { error: Option, } -#[host_fn] -extern "ExtismHost" { - fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; - fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; - fn scrobbleretriever_getscrobbles(input: Json) -> Json; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountRequest { + username: String, + options: ScrobbleCountOptions, } -/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -/// -/// # Arguments -/// * `username` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_last_timestamp(username: &str) -> Result, Error> { - let response = unsafe { - scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { - username: username.to_owned(), - }))? - }; +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountResponse { + #[serde(default)] + result: i64, + #[serde(default)] + error: Option, +} - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) +#[host_fn] +extern "ExtismHost" { + fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; + fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; + fn scrobbleretriever_getscrobbles(input: Json) -> Json; + fn scrobbleretriever_getscrobblecount(input: Json) -> Json; } /// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user @@ -133,6 +135,30 @@ pub fn get_first_timestamp(username: &str) -> Result, Error> { Ok(response.0.result) } +/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_last_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + /// Calls the scrobbleretriever_getscrobbles host function. /// /// # Arguments @@ -158,3 +184,29 @@ pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result Result { + let response = unsafe { + scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod index 6265288f9..59486d796 100644 --- a/plugins/testdata/test-scrobble-retriever/go.mod +++ b/plugins/testdata/test-scrobble-retriever/go.mod @@ -1,4 +1,4 @@ -module test-sonic-similarity +module test-scrobble-retriever go 1.25 diff --git a/plugins/testdata/test-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go index 21102bf94..c9ab17eec 100644 --- a/plugins/testdata/test-scrobble-retriever/main.go +++ b/plugins/testdata/test-scrobble-retriever/main.go @@ -1,6 +1,8 @@ package main import ( + "strconv" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -40,3 +42,65 @@ func callGetLastTimestamp() int32 { pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) return 0 } + +type TestScrobbleOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + MaxItems int `json:"maxItems"` +} + +//go:wasmexport call_get_scrobbles +func callGetScrobbles() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + scrobbles, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + MaxItems: options.MaxItems, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(scrobbles) + return 0 +} + +type TestScrobbleCountOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +//go:wasmexport call_get_scrobbles_count +func callGetScrobblesCount() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + count, err := host.ScrobbleRetrieverGetScrobbleCount(options.Username, host.ScrobbleCountOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputString(strconv.FormatInt(count, 10)) + return 0 +} From cd2643f83eaebc08ddba49d7f42503e2cb9bdb5b Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:39:59 -0700 Subject: [PATCH 10/11] add docs, test for rejected user --- plugins/host/scrobble_retriever.go | 64 +++++++++++++++--- plugins/host_scrobbleretriever_test.go | 65 ++++++++++++++----- .../pdk/go/host/nd_host_scrobbleretriever.go | 33 +++++++++- .../go/host/nd_host_scrobbleretriever_stub.go | 33 +++++++++- .../src/nd_host_scrobbleretriever.rs | 35 +++++++++- 5 files changed, 200 insertions(+), 30 deletions(-) diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go index 569dc8641..173989c04 100644 --- a/plugins/host/scrobble_retriever.go +++ b/plugins/host/scrobble_retriever.go @@ -2,26 +2,46 @@ package host import "context" +// ScrobbleList is a list of scrobbles, plus an optional timestamp +// that can be used as a cursor for the next fetch type ScrobbleList struct { - Scrobbles []ScrobbleRef `json:"scrobbles"` - NextTimestamp *int64 `json:"nextTimestamp,omitempty"` + // The scrobbles in a given range + Scrobbles []ScrobbleRef `json:"scrobbles"` + // If additional items are available, the timestamp of the next scrobble to fetch + NextTimestamp *int64 `json:"nextTimestamp,omitempty"` } +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) type ScrobbleRef struct { - ID int64 `json:"id"` - MediaFileID string `json:"mediaFileId"` - SubmissionTime int64 `json:"submissionTime"` + // The ID of the scrobble. Useful if duplicate scrobbles happen for the same time + ID int64 `json:"id"` + // The ID of the MediaFile submitted at this time + MediaFileID string `json:"mediaFileId"` + // The UNIX timestamp this scrobble was submitted + SubmissionTime int64 `json:"submissionTime"` } +// ScrobbleOptions carries optional parameters for retrieving user scrobbles type ScrobbleOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - MaxItems int `json:"maxItems"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + // The maximum number of items to retrieve. This is capped at 5000, the + // default if not specified + MaxItems int `json:"maxItems"` } +// ScrobbleCountOptions carries optional parameters for counting user scrobbles type ScrobbleCountOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` } // ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. @@ -30,17 +50,43 @@ type ScrobbleCountOptions struct { // //nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever type ScrobbleRetrieverService interface { - // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user + // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. + // If the user has no scrobbles, returns nil //nd:hostfunc GetFirstTimestamp(ctx context.Context, username string) (*int64, error) // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user + // If the user has no scrobbles, return nil //nd:hostfunc GetLastTimestamp(ctx context.Context, username string) (*int64, error) + // GetScrobbles returns scrobbles for a user. + // + // Parameters: + // - username: the user to query for scrobbles + // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble + // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble + // - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 + // + // Returns: + // - Scrobbles: A list of scrobbles within the constraints given (if any). The order + // of the items depends on the options: if ToTimestamp is specified AND + // FromTImestamp is not specified, the order is in descending submission time. + // Otherwise, the scrobbles are returned in ascending submission time. + // - NextTimestamp: If there are additional items to retrieve in the range, the timestamp + // of the next scrobble that would be retrieved in the order (asc or desc) //nd:hostfunc GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (*ScrobbleList, error) + // GetScrobbleCount returns the number of scrobbles for a user in a given range + // + // Parameters: + // - username: the user to query for scrobbles + // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble + // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble + // + // Returns: + // - the number of scrobbles in the given range, or 0 //nd:hostfunc GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) } diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index 8175c7d06..031bda154 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -130,16 +130,58 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() { }) }) - Describe("no items", func() { - var plugin *plugin + var plugin *plugin - BeforeEach(func() { - manager.mu.RLock() - plugin = manager.plugins["test-scrobble-retriever"] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + Describe("not authorized", func() { + It("rejects first timestamp", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) }) + It("rejects last timestamp", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + instance, err := plugin.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(GinkgoT().Context()) + + exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + }) + + Describe("no items", func() { It("calls get first timestamp", func() { instance, err := plugin.instance(GinkgoT().Context()) Expect(err).ToNot(HaveOccurred()) @@ -189,8 +231,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() { }) Describe("with items", func() { - var plugin *plugin - p := func(val int64) *int64 { return &val } @@ -210,13 +250,6 @@ var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() { } }) - BeforeEach(func() { - manager.mu.RLock() - plugin = manager.plugins["test-scrobble-retriever"] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - }) - It("calls get first timestamp", func() { instance, err := plugin.instance(GinkgoT().Context()) Expect(err).ToNot(HaveOccurred()) diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go index c73ff5073..efe981ac3 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -15,18 +15,22 @@ import ( ) // ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles type ScrobbleCountOptions struct { FromTimestamp *int64 `json:"fromTimestamp"` ToTimestamp *int64 `json:"toTimestamp"` } // ScrobbleList represents the ScrobbleList data structure. +// ScrobbleList is a list of scrobbles, plus an optional timestamp +// that can be used as a cursor for the next fetch type ScrobbleList struct { Scrobbles []ScrobbleRef `json:"scrobbles"` NextTimestamp *int64 `json:"nextTimestamp"` } // ScrobbleOptions represents the ScrobbleOptions data structure. +// ScrobbleOptions carries optional parameters for retrieving user scrobbles type ScrobbleOptions struct { FromTimestamp *int64 `json:"fromTimestamp"` ToTimestamp *int64 `json:"toTimestamp"` @@ -34,6 +38,7 @@ type ScrobbleOptions struct { } // ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) type ScrobbleRef struct { ID int64 `json:"id"` MediaFileID string `json:"mediaFileId"` @@ -99,7 +104,8 @@ type scrobbleRetrieverGetScrobbleCountResponse struct { } // ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. -// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { // Marshal request to JSON req := scrobbleRetrieverGetFirstTimestampRequest{ @@ -135,6 +141,7 @@ func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { // ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { // Marshal request to JSON req := scrobbleRetrieverGetLastTimestampRequest{ @@ -169,6 +176,21 @@ func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { } // ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. +// GetScrobbles returns scrobbles for a user. +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +// +// Returns: +// - Scrobbles: A list of scrobbles within the constraints given (if any). The order +// of the items depends on the options: if ToTimestamp is specified AND +// FromTImestamp is not specified, the order is in descending submission time. +// Otherwise, the scrobbles are returned in ascending submission time. +// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp +// of the next scrobble that would be retrieved in the order (asc or desc) func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { // Marshal request to JSON req := scrobbleRetrieverGetScrobblesRequest{ @@ -204,6 +226,15 @@ func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*S } // ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// +// Returns: +// - the number of scrobbles in the given range, or 0 func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { // Marshal request to JSON req := scrobbleRetrieverGetScrobbleCountRequest{ diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go index 12bc46887..c6de90ebd 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -13,18 +13,22 @@ import ( ) // ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles type ScrobbleCountOptions struct { FromTimestamp *int64 `json:"fromTimestamp"` ToTimestamp *int64 `json:"toTimestamp"` } // ScrobbleList represents the ScrobbleList data structure. +// ScrobbleList is a list of scrobbles, plus an optional timestamp +// that can be used as a cursor for the next fetch type ScrobbleList struct { Scrobbles []ScrobbleRef `json:"scrobbles"` NextTimestamp *int64 `json:"nextTimestamp"` } // ScrobbleOptions represents the ScrobbleOptions data structure. +// ScrobbleOptions carries optional parameters for retrieving user scrobbles type ScrobbleOptions struct { FromTimestamp *int64 `json:"fromTimestamp"` ToTimestamp *int64 `json:"toTimestamp"` @@ -32,6 +36,7 @@ type ScrobbleOptions struct { } // ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) type ScrobbleRef struct { ID int64 `json:"id"` MediaFileID string `json:"mediaFileId"` @@ -54,7 +59,8 @@ func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int6 } // ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. -// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { return ScrobbleRetrieverMock.GetFirstTimestamp(username) } @@ -67,6 +73,7 @@ func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64 // ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { return ScrobbleRetrieverMock.GetLastTimestamp(username) } @@ -78,6 +85,21 @@ func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options Scr } // ScrobbleRetrieverGetScrobbles delegates to the mock instance. +// GetScrobbles returns scrobbles for a user. +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +// +// Returns: +// - Scrobbles: A list of scrobbles within the constraints given (if any). The order +// of the items depends on the options: if ToTimestamp is specified AND +// FromTImestamp is not specified, the order is in descending submission time. +// Otherwise, the scrobbles are returned in ascending submission time. +// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp +// of the next scrobble that would be retrieved in the order (asc or desc) func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { return ScrobbleRetrieverMock.GetScrobbles(username, options) } @@ -89,6 +111,15 @@ func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options } // ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// +// Returns: +// - the number of scrobbles in the given range, or 0 func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { return ScrobbleRetrieverMock.GetScrobbleCount(username, options) } diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs index c3062ab4d..709e40ac8 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -6,6 +6,7 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +/// ScrobbleCountOptions carries optional parameters for counting user scrobbles #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleCountOptions { @@ -15,6 +16,8 @@ pub struct ScrobbleCountOptions { pub to_timestamp: Option, } +/// ScrobbleList is a list of scrobbles, plus an optional timestamp +/// that can be used as a cursor for the next fetch #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleList { @@ -23,6 +26,7 @@ pub struct ScrobbleList { pub next_timestamp: Option, } +/// ScrobbleOptions carries optional parameters for retrieving user scrobbles #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleOptions { @@ -33,6 +37,7 @@ pub struct ScrobbleOptions { pub max_items: i32, } +/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleRef { @@ -111,7 +116,8 @@ extern "ExtismHost" { fn scrobbleretriever_getscrobblecount(input: Json) -> Json; } -/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user +/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +/// If the user has no scrobbles, returns nil /// /// # Arguments /// * `username` - String parameter. @@ -136,6 +142,7 @@ pub fn get_first_timestamp(username: &str) -> Result, Error> { } /// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// If the user has no scrobbles, return nil /// /// # Arguments /// * `username` - String parameter. @@ -159,7 +166,21 @@ pub fn get_last_timestamp(username: &str) -> Result, Error> { Ok(response.0.result) } -/// Calls the scrobbleretriever_getscrobbles host function. +/// GetScrobbles returns scrobbles for a user. +/// +/// Parameters: +/// - username: the user to query for scrobbles +/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +/// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +/// +/// Returns: +/// - Scrobbles: A list of scrobbles within the constraints given (if any). The order +/// of the items depends on the options: if ToTimestamp is specified AND +/// FromTImestamp is not specified, the order is in descending submission time. +/// Otherwise, the scrobbles are returned in ascending submission time. +/// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp +/// of the next scrobble that would be retrieved in the order (asc or desc) /// /// # Arguments /// * `username` - String parameter. @@ -185,7 +206,15 @@ pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result Date: Sun, 26 Jul 2026 06:26:21 -0700 Subject: [PATCH 11/11] add permission validation for scrobble retriever --- plugins/manifest.go | 8 ++++++-- plugins/manifest_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/plugins/manifest.go b/plugins/manifest.go index 5e144b5c8..f8589a97f 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -53,10 +53,14 @@ func ParseManifest(data []byte) (*Manifest, error) { // This validates rules like "SubsonicAPI permission requires users permission". func (m *Manifest) Validate() error { // SubsonicAPI permission requires users permission - if m.Permissions != nil && m.Permissions.Subsonicapi != nil { - if m.Permissions.Users == nil { + if m.Permissions != nil && m.Permissions.Users == nil { + if m.Permissions.Subsonicapi != nil { return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") } + + if m.Permissions.ScrobbleRetriever != nil { + return fmt.Errorf("'scrobbleRetriever' permission requires 'users' permission to be declared") + } } // Matcher returns library content, so it requires the library permission (which diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 32bcbba08..bfd8bcac1 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -222,6 +222,36 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("library")) }) + It("validates manifest with scrobbleRetriever and users permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + Users: &UsersPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when scrobbleRetriever without users permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("scrobbleRetriever")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test",