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)