From 4998ac2c591d7c1b17bf36f5109f64f521d1169c Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:32:03 +0000 Subject: [PATCH] feat(server): add scrobble history Native API (#5761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial scrobble api * feat: add scrobble retrieval api * address feedback (1) * fix spelling * be explicit about get * add primary key field, update index, remove rowid references * use unix timestamp for input and output --------- Co-authored-by: Deluan Quintão --- core/scrobbler/play_tracker_test.go | 2 +- ...ary_key_and_update_index_for_scrobbles.sql | 39 ++++ model/scrobble.go | 12 +- persistence/persistence.go | 2 + persistence/persistence_suite_test.go | 20 ++ persistence/scrobble_repository.go | 65 +++++++ persistence/scrobble_repository_test.go | 181 +++++++++++++++--- server/nativeapi/native_api.go | 3 +- tests/mock_scrobble_repo.go | 23 ++- 9 files changed, 318 insertions(+), 29 deletions(-) create mode 100644 db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql 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/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/model/scrobble.go b/model/scrobble.go index e1567abc3..a8022fc16 100644 --- a/model/scrobble.go +++ b/model/scrobble.go @@ -3,11 +3,17 @@ package model import "time" type Scrobble struct { - MediaFileID string - UserID string - SubmissionTime time.Time + 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 { + 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..4f2fd7fe2 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{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} +) + 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, + })) + if err != nil { + panic(err) + } + } }) func GetDBXBuilder() *dbx.DB { diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go index 219a48198..7cc60ae23 100644 --- a/persistence/scrobble_repository.go +++ b/persistence/scrobble_repository.go @@ -5,6 +5,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" ) @@ -13,11 +14,34 @@ type scrobbleRepository struct { sqlRepository } +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("id", "media_file_id", "submission_time"). + 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, + }) + r.setSortMappings(map[string]string{ + "submission_time": "submission_time", + }) return r } @@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t _, err := r.executeSQL(insert) return err } + +func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.baseQuery(), options...) +} + +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{"id": id}) + var res model.Scrobble + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) { + sel := r.baseQuery(options...) + var scrobbles model.Scrobbles + err := r.queryAll(sel, &scrobbles) + return scrobbles, err +} + +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..e9103b127 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()) + Expect(scrobble.ID).To(Equal(int64(1))) + Expect(scrobble.MediaFileID).To(Equal("1001")) + Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) + + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("2") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + 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)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + 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(Equal(firstScrobble.SubmissionTime)) + }) + + 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)) + + Expect(scrobbles[0].ID).To(Equal(int64(3))) + Expect(scrobbles[0].MediaFileID).To(Equal("1002")) + Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) + }) + }) + }) + + 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()) + Expect(scrobble.ID).To(Equal(int64(2))) + Expect(scrobble.MediaFileID).To(Equal("1003")) + Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + It("does not return a scrobble that exists for another user", func() { + _, err := repo.Get("1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not return a scrobble that does not exist", func() { + _, err := repo.Get("444") + 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)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + + 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)) + + Expect(scrobbles[0].ID).To(Equal(int64(2))) + Expect(scrobbles[0].MediaFileID).To(Equal("1003")) + Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) + }) + }) + }) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 669c4d7b5..5a7023eb6 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -72,7 +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, "/tag", model.Tag{}, false) + api.R(r, "/scrobble", model.Scrobble{}, false) 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..d6d88d221 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" @@ -13,12 +14,32 @@ type MockScrobbleRepo struct { ctx context.Context } +func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) { + for idx := range m.RecordedScrobbles { + if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { + return &m.RecordedScrobbles[idx], 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{ MediaFileID: fileID, UserID: user.ID, - SubmissionTime: submissionTime, + SubmissionTime: submissionTime.Unix(), }) return nil } + +var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil)