mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
969e7e108c
commit
4998ac2c59
@ -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() {
|
||||
|
||||
@ -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);
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user