mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
initial scrobble api
This commit is contained in:
parent
7fa13761d7
commit
43e9ade8a3
@ -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
|
||||
|
||||
@ -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{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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user