initial scrobble api

This commit is contained in:
Kendall Garner 2026-07-10 15:35:19 -07:00
parent 7fa13761d7
commit 43e9ade8a3
No known key found for this signature in database
GPG Key ID: 9355F387FE765C94
7 changed files with 332 additions and 26 deletions

View File

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

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

View File

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

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

View File

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

View File

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