feat(server): track scrobble/linstens history (#4770)

* feat(scrobble): implement scrobble repository and record scrobble history

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(scrobble): add configuration option to enable scrobble history

Signed-off-by: Deluan <deluan@navidrome.org>

* test(scrobble): enhance scrobble history tests for repository recording

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2025-12-06 11:07:18 -05:00
committed by GitHub
parent bfd219e708
commit a521c74a59
12 changed files with 241 additions and 8 deletions
+4
View File
@@ -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:
+34
View File
@@ -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
}
+84
View File
@@ -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()))
})
})
})