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

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-05 20:43:20 -05:00
parent bfd219e708
commit 9d4af721ca
11 changed files with 209 additions and 3 deletions

3
.gitignore vendored
View File

@ -31,4 +31,5 @@ AGENTS.md
.github/git-commit-instructions.md
*.exe
*.test
*.wasm
*.wasm
openspec/

View File

@ -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)
})
}

View File

@ -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()

View File

@ -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

View File

@ -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

13
model/scrobble.go Normal file
View File

@ -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
}

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:

View File

@ -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
}

View File

@ -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))
})
})
})

View File

@ -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 {

View File

@ -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
}