mirror of
https://github.com/navidrome/navidrome.git
synced 2025-12-23 23:18:05 -05:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,4 +31,5 @@ AGENTS.md
|
|||||||
.github/git-commit-instructions.md
|
.github/git-commit-instructions.md
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.wasm
|
*.wasm
|
||||||
|
openspec/
|
||||||
@@ -102,7 +102,8 @@ type configOptions struct {
|
|||||||
Spotify spotifyOptions `json:",omitzero"`
|
Spotify spotifyOptions `json:",omitzero"`
|
||||||
Deezer deezerOptions `json:",omitzero"`
|
Deezer deezerOptions `json:",omitzero"`
|
||||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||||
Tags map[string]TagConf `json:",omitempty"`
|
EnableScrobbleHistory bool
|
||||||
|
Tags map[string]TagConf `json:",omitempty"`
|
||||||
Agents string
|
Agents string
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
@@ -598,6 +599,7 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("deezer.language", "en")
|
viper.SetDefault("deezer.language", "en")
|
||||||
viper.SetDefault("listenbrainz.enabled", true)
|
viper.SetDefault("listenbrainz.enabled", true)
|
||||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||||
|
viper.SetDefault("enablescrobblehistory", true)
|
||||||
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
|
||||||
viper.SetDefault("backup.path", "")
|
viper.SetDefault("backup.path", "")
|
||||||
viper.SetDefault("backup.schedule", "")
|
viper.SetDefault("backup.schedule", "")
|
||||||
|
|||||||
@@ -345,8 +345,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
|
|||||||
}
|
}
|
||||||
for _, artist := range track.Participants[model.RoleArtist] {
|
for _, artist := range track.Participants[model.RoleArtist] {
|
||||||
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
ctx = GinkgoT().Context()
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
@@ -177,9 +177,9 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
track2 := track
|
track2 := track
|
||||||
track2.ID = "456"
|
track2.ID = "456"
|
||||||
_ = ds.MediaFile(ctx).Put(&track2)
|
_ = 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)
|
_ = 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)
|
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
|
||||||
|
|
||||||
playing, err := tracker.GetNowPlaying(ctx)
|
playing, err := tracker.GetNowPlaying(ctx)
|
||||||
@@ -291,6 +291,38 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
Expect(artist1.PlayCount).To(Equal(int64(1)))
|
Expect(artist1.PlayCount).To(Equal(int64(1)))
|
||||||
Expect(artist2.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() {
|
Describe("Plugin scrobbler logic", func() {
|
||||||
@@ -352,7 +384,7 @@ var _ = Describe("PlayTracker", func() {
|
|||||||
var mockedBS *mockBufferedScrobbler
|
var mockedBS *mockBufferedScrobbler
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = context.Background()
|
ctx = GinkgoT().Context()
|
||||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
|
|||||||
20
db/migrations/20251206013022_create_scrobbles_table.sql
Normal file
20
db/migrations/20251206013022_create_scrobbles_table.sql
Normal 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
|
||||||
@@ -38,6 +38,7 @@ type DataStore interface {
|
|||||||
User(ctx context.Context) UserRepository
|
User(ctx context.Context) UserRepository
|
||||||
UserProps(ctx context.Context) UserPropsRepository
|
UserProps(ctx context.Context) UserPropsRepository
|
||||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||||
|
Scrobble(ctx context.Context) ScrobbleRepository
|
||||||
|
|
||||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||||
|
|
||||||
|
|||||||
13
model/scrobble.go
Normal file
13
model/scrobble.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
|
|||||||
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
|
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 {
|
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||||
switch m.(type) {
|
switch m.(type) {
|
||||||
case model.User:
|
case model.User:
|
||||||
|
|||||||
34
persistence/scrobble_repository.go
Normal file
34
persistence/scrobble_repository.go
Normal 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
persistence/scrobble_repository_test.go
Normal file
84
persistence/scrobble_repository_test.go
Normal 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()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,7 @@ type MockDataStore struct {
|
|||||||
MockedTranscoding model.TranscodingRepository
|
MockedTranscoding model.TranscodingRepository
|
||||||
MockedUserProps model.UserPropsRepository
|
MockedUserProps model.UserPropsRepository
|
||||||
MockedScrobbleBuffer model.ScrobbleBufferRepository
|
MockedScrobbleBuffer model.ScrobbleBufferRepository
|
||||||
|
MockedScrobble model.ScrobbleRepository
|
||||||
MockedRadio model.RadioRepository
|
MockedRadio model.RadioRepository
|
||||||
scrobbleBufferMu sync.Mutex
|
scrobbleBufferMu sync.Mutex
|
||||||
repoMu sync.Mutex
|
repoMu sync.Mutex
|
||||||
@@ -208,12 +209,23 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe
|
|||||||
if db.RealDS != nil {
|
if db.RealDS != nil {
|
||||||
db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx)
|
db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx)
|
||||||
} else {
|
} else {
|
||||||
db.MockedScrobbleBuffer = CreateMockedScrobbleBufferRepo()
|
db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return db.MockedScrobbleBuffer
|
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 {
|
func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
|
||||||
if db.MockedRadio == nil {
|
if db.MockedRadio == nil {
|
||||||
if db.RealDS != nil {
|
if db.RealDS != nil {
|
||||||
|
|||||||
24
tests/mock_scrobble_repo.go
Normal file
24
tests/mock_scrobble_repo.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user