From 9d4af721ca90c259ca1688250139ef8c105758e8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Dec 2025 20:43:20 -0500 Subject: [PATCH] feat(scrobble): implement scrobble repository and record scrobble history Signed-off-by: Deluan --- .gitignore | 3 +- core/scrobbler/play_tracker.go | 10 ++- core/scrobbler/play_tracker_test.go | 16 ++++ .../20251206013022_create_scrobbles_table.sql | 20 +++++ model/datastore.go | 1 + model/scrobble.go | 13 ++++ persistence/persistence.go | 4 + persistence/scrobble_repository.go | 33 ++++++++ persistence/scrobble_repository_test.go | 78 +++++++++++++++++++ tests/mock_data_store.go | 14 +++- tests/mock_scrobble_repo.go | 20 +++++ 11 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 db/migrations/20251206013022_create_scrobbles_table.sql create mode 100644 model/scrobble.go create mode 100644 persistence/scrobble_repository.go create mode 100644 persistence/scrobble_repository_test.go create mode 100644 tests/mock_scrobble_repo.go diff --git a/.gitignore b/.gitignore index 74d7ee46f..03852f9ab 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,5 @@ AGENTS.md .github/git-commit-instructions.md *.exe *.test -*.wasm \ No newline at end of file +*.wasm +openspec/ \ No newline at end of file diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d7ab0e6cf..30b196920 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -2,6 +2,7 @@ package scrobbler import ( "context" + "fmt" "maps" "sort" "sync" @@ -334,6 +335,10 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro } func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error { + user, ok := request.UserFrom(ctx) + if !ok { + return fmt.Errorf("user not found in context") + } return p.ds.WithTx(func(tx model.DataStore) error { err := tx.MediaFile(ctx).IncPlayCount(track.ID, timestamp) if err != nil { @@ -345,8 +350,11 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times } for _, artist := range track.Participants[model.RoleArtist] { err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp) + if err != nil { + return err + } } - return err + return tx.Scrobble(ctx).RecordScrobble(track.ID, user.ID, timestamp) }) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f300f7796..0736afeea 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -234,6 +234,22 @@ var _ = Describe("PlayTracker", func() { Expect(lastScrobble.Participants).To(Equal(track.Participants)) }) + It("records scrobble in repository", func() { + ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) + ts := time.Now() + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) + + Expect(err).ToNot(HaveOccurred()) + + mockDS := ds.(*tests.MockDataStore) + mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo) + 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)) + }) + It("increments play counts in the DB", func() { ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) ts := time.Now() diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql new file mode 100644 index 000000000..9791c48e3 --- /dev/null +++ b/db/migrations/20251206013022_create_scrobbles_table.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE scrobbles( + 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 +); +CREATE INDEX scrobbles_date ON scrobbles (submission_time); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE scrobbles; +-- +goose StatementEnd diff --git a/model/datastore.go b/model/datastore.go index 536a37274..601fab2d3 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -38,6 +38,7 @@ type DataStore interface { User(ctx context.Context) UserRepository UserProps(ctx context.Context) UserPropsRepository ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository + Scrobble(ctx context.Context) ScrobbleRepository Resource(ctx context.Context, model interface{}) ResourceRepository diff --git a/model/scrobble.go b/model/scrobble.go new file mode 100644 index 000000000..8acabe689 --- /dev/null +++ b/model/scrobble.go @@ -0,0 +1,13 @@ +package model + +import "time" + +type Scrobble struct { + MediaFileID string + UserID string + SubmissionTime time.Time +} + +type ScrobbleRepository interface { + RecordScrobble(mediaFileID, userID string, submissionTime time.Time) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 1de0bae61..9599de179 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos return NewScrobbleBufferRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + return NewScrobbleRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go new file mode 100644 index 000000000..c4663033c --- /dev/null +++ b/persistence/scrobble_repository.go @@ -0,0 +1,33 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type scrobbleRepository struct { + sqlRepository +} + +func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { + r := &scrobbleRepository{} + r.ctx = ctx + r.db = db + r.tableName = "scrobbles" + return r +} + +func (r *scrobbleRepository) RecordScrobble(mediaFileID, userID string, submissionTime time.Time) error { + values := map[string]interface{}{ + "media_file_id": mediaFileID, + "user_id": userID, + "submission_time": submissionTime.Unix(), + } + insert := Insert(r.tableName).SetMap(values) + _, err := r.executeSQL(insert) + return err +} diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go new file mode 100644 index 000000000..173263c62 --- /dev/null +++ b/persistence/scrobble_repository_test.go @@ -0,0 +1,78 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("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(context.TODO()), 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() { + It("records a scrobble event", func() { + submissionTime := time.Now().UTC() + + // Insert User + _, err := rawRepo.db.Insert("user", dbx.Params{ + "id": userID, + "user_name": "user", + "password": "pw", + "created_at": time.Now(), + "updated_at": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + // Insert MediaFile + _, err = rawRepo.db.Insert("media_file", dbx.Params{ + "id": fileID, + "path": "path", + "created_at": time.Now(), + "updated_at": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + + err = repo.RecordScrobble(fileID, userID, submissionTime) + Expect(err).ToNot(HaveOccurred()) + + // Verify insertion + var count int + err = rawRepo.db.Select("count(*)").From("scrobbles"). + Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}). + Row(&count) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(1)) + }) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index ba586ab53..76555e52d 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -25,6 +25,7 @@ type MockDataStore struct { MockedTranscoding model.TranscodingRepository MockedUserProps model.UserPropsRepository MockedScrobbleBuffer model.ScrobbleBufferRepository + MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -208,12 +209,23 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe if db.RealDS != nil { db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx) } else { - db.MockedScrobbleBuffer = CreateMockedScrobbleBufferRepo() + db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} } } return db.MockedScrobbleBuffer } +func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + if db.MockedScrobble == nil { + if db.RealDS != nil { + db.MockedScrobble = db.RealDS.Scrobble(ctx) + } else { + db.MockedScrobble = &MockScrobbleRepo{} + } + } + return db.MockedScrobble +} + func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { if db.MockedRadio == nil { if db.RealDS != nil { diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go new file mode 100644 index 000000000..be76aff27 --- /dev/null +++ b/tests/mock_scrobble_repo.go @@ -0,0 +1,20 @@ +package tests + +import ( + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockScrobbleRepo struct { + RecordedScrobbles []model.Scrobble +} + +func (m *MockScrobbleRepo) RecordScrobble(fileID, userID string, submissionTime time.Time) error { + m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ + MediaFileID: fileID, + UserID: userID, + SubmissionTime: submissionTime, + }) + return nil +}