feat(server): add scrobble history Native API (#5761)

* initial scrobble api

* feat: add scrobble retrieval api

* address feedback (1)

* fix spelling

* be explicit about get

* add primary key field, update index, remove rowid references

* use unix timestamp for input and output

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
Kendall Garner 2026-07-13 15:32:03 +00:00 committed by GitHub
parent 969e7e108c
commit 4998ac2c59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 318 additions and 29 deletions

View File

@ -290,7 +290,7 @@ var _ = Describe("PlayTracker", func() {
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))
Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix()))
})
It("does not record scrobble when history is disabled", func() {

View File

@ -0,0 +1,39 @@
-- +goose Up
CREATE TABLE scrobbles_tmp(
id INTEGER PRIMARY KEY,
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
);
INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles;
DROP INDEX scrobbles_date;
DROP TABLE scrobbles;
ALTER TABLE scrobbles_tmp RENAME TO scrobbles;
CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time);
-- +goose Down
CREATE TABLE scrobbles_tmp(
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
);
INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles;
DROP INDEX scrobbles_user_time;
DROP TABLE scrobbles;
ALTER TABLE scrobbles_tmp RENAME TO scrobbles;
CREATE INDEX scrobbles_date ON scrobbles(submission_time);

View File

@ -3,11 +3,17 @@ package model
import "time"
type Scrobble struct {
MediaFileID string
UserID string
SubmissionTime time.Time
ID int64 `structs:"id" json:"id"`
MediaFileID string `structs:"media_file_id" json:"mediaFileId"`
UserID string `json:"-"`
SubmissionTime int64 `structs:"submission_time" json:"submissionTime"`
}
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

View File

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

View File

@ -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{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
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,
}))
if err != nil {
panic(err)
}
}
})
func GetDBXBuilder() *dbx.DB {

View File

@ -5,6 +5,7 @@ import (
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/pocketbase/dbx"
)
@ -13,11 +14,34 @@ type scrobbleRepository struct {
sqlRepository
}
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("id", "media_file_id", "submission_time").
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,
})
r.setSortMappings(map[string]string{
"submission_time": "submission_time",
})
return r
}
@ -32,3 +56,44 @@ func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime t
_, err := r.executeSQL(insert)
return err
}
func (r *scrobbleRepository) CountAll(options ...model.QueryOptions) (int64, error) {
return r.count(r.baseQuery(), options...)
}
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{"id": id})
var res model.Scrobble
err := r.queryOne(sel, &res)
return &res, err
}
func (r *scrobbleRepository) GetAll(options ...model.QueryOptions) (model.Scrobbles, error) {
sel := r.baseQuery(options...)
var scrobbles model.Scrobbles
err := r.queryAll(sel, &scrobbles)
return scrobbles, err
}
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)

View File

@ -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())
Expect(scrobble.ID).To(Equal(int64(1)))
Expect(scrobble.MediaFileID).To(Equal("1001"))
Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime))
})
It("does not return a scrobble that exists for another user", func() {
_, err := repo.Get("2")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not return a scrobble that does not exist", func() {
_, err := repo.Get("444")
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))
Expect(scrobbles[0].ID).To(Equal(int64(3)))
Expect(scrobbles[0].MediaFileID).To(Equal("1002"))
Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime))
Expect(scrobbles[1].ID).To(Equal(int64(1)))
Expect(scrobbles[1].MediaFileID).To(Equal("1001"))
Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime))
})
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))
Expect(scrobbles[0].ID).To(Equal(int64(3)))
Expect(scrobbles[0].MediaFileID).To(Equal("1002"))
Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime))
})
})
})
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())
Expect(scrobble.ID).To(Equal(int64(2)))
Expect(scrobble.MediaFileID).To(Equal("1003"))
Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
})
It("does not return a scrobble that exists for another user", func() {
_, err := repo.Get("1")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("does not return a scrobble that does not exist", func() {
_, err := repo.Get("444")
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))
Expect(scrobbles[0].ID).To(Equal(int64(2)))
Expect(scrobbles[0].MediaFileID).To(Equal("1003"))
Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
})
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))
Expect(scrobbles[0].ID).To(Equal(int64(2)))
Expect(scrobbles[0].MediaFileID).To(Equal("1003"))
Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime))
})
})
})
})

View File

@ -72,7 +72,8 @@ func (api *Router) routes() http.Handler {
api.R(r, "/player", model.Player{}, true)
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
api.addRadioRoute(r)
api.R(r, "/tag", model.Tag{}, true)
api.R(r, "/tag", model.Tag{}, false)
api.R(r, "/scrobble", model.Scrobble{}, false)
if conf.Server.EnableSharing {
api.RX(r, "/share", api.share.NewRepository, true)
}

View File

@ -2,6 +2,7 @@ package tests
import (
"context"
"strconv"
"time"
"github.com/navidrome/navidrome/model"
@ -13,12 +14,32 @@ type MockScrobbleRepo struct {
ctx context.Context
}
func (m *MockScrobbleRepo) Get(id string) (*model.Scrobble, error) {
for idx := range m.RecordedScrobbles {
if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id {
return &m.RecordedScrobbles[idx], 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{
MediaFileID: fileID,
UserID: user.ID,
SubmissionTime: submissionTime,
SubmissionTime: submissionTime.Unix(),
})
return nil
}
var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil)