diff --git a/.gitignore b/.gitignore index 74d7ee46..03852f9a 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/conf/configuration.go b/conf/configuration.go index 58785952..1a01d22c 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -102,7 +102,8 @@ type configOptions struct { Spotify spotifyOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` - Tags map[string]TagConf `json:",omitempty"` + EnableScrobbleHistory bool + Tags map[string]TagConf `json:",omitempty"` Agents string // DevFlags. These are used to enable/disable debugging and incomplete features @@ -598,6 +599,7 @@ func setViperDefaults() { viper.SetDefault("deezer.language", "en") viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") + viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY") viper.SetDefault("backup.path", "") viper.SetDefault("backup.schedule", "") diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d7ab0e6c..bac9d220 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -345,8 +345,14 @@ 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 + if conf.Server.EnableScrobbleHistory { + return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp) + } + return nil }) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f300f779..6f66276c 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -61,7 +61,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() + ctx = GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} @@ -177,9 +177,9 @@ var _ = Describe("PlayTracker", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) - ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"}) + ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"}) + ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) playing, err := tracker.GetNowPlaying(ctx) @@ -291,6 +291,38 @@ var _ = Describe("PlayTracker", func() { Expect(artist1.PlayCount).To(Equal(int64(1))) Expect(artist2.PlayCount).To(Equal(int64(1))) }) + + Context("Scrobble History", func() { + It("records scrobble in repository", func() { + conf.Server.EnableScrobbleHistory = true + 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("does not record scrobble when history is disabled", func() { + conf.Server.EnableScrobbleHistory = false + 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(0)) + }) + }) }) Describe("Plugin scrobbler logic", func() { @@ -352,7 +384,7 @@ var _ = Describe("PlayTracker", func() { var mockedBS *mockBufferedScrobbler BeforeEach(func() { - ctx = context.Background() + ctx = GinkgoT().Context() ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql new file mode 100644 index 00000000..9791c48e --- /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 536a3727..601fab2d 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 00000000..e1567abc --- /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 string, submissionTime time.Time) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 1de0bae6..9599de17 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 00000000..dda98b76 --- /dev/null +++ b/persistence/scrobble_repository.go @@ -0,0 +1,34 @@ +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 string, submissionTime time.Time) error { + userID := loggedUser(r.ctx).ID + 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 00000000..d43848d0 --- /dev/null +++ b/persistence/scrobble_repository_test.go @@ -0,0 +1,84 @@ +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(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() { + 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, submissionTime) + Expect(err).ToNot(HaveOccurred()) + + // Verify insertion + var scrobble struct { + MediaFileID string `db:"media_file_id"` + UserID string `db:"user_id"` + SubmissionTime int64 `db:"submission_time"` + } + err = rawRepo.db.Select("*").From("scrobbles"). + Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}). + One(&scrobble) + Expect(err).ToNot(HaveOccurred()) + Expect(scrobble.MediaFileID).To(Equal(fileID)) + Expect(scrobble.UserID).To(Equal(userID)) + Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix())) + }) + }) +}) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index ba586ab5..8ac7b58a 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{ctx: ctx} + } + } + 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 00000000..34561c25 --- /dev/null +++ b/tests/mock_scrobble_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +type MockScrobbleRepo struct { + RecordedScrobbles []model.Scrobble + ctx context.Context +} + +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, + }) + return nil +}