refactor(plugins): return the next page's options from GetScrobbles

Paging previously meant reading NextTimestamp and Cursor off the response and
deciding where each belonged: NextTimestamp into FromTimestamp when ascending
or ToTimestamp when descending, and Cursor copied every time, including when 0.
Both are silent data-loss bugs when a plugin gets them wrong.

GetScrobbles now returns the options for the following page, or nil when the
range is exhausted, so a plugin passes the value straight back and repeats.
ScrobbleCursor and ScrobbleList are gone; the query itself is unchanged.
This commit is contained in:
Deluan 2026-08-08 14:11:09 -04:00
parent 22d94877fe
commit f49b8bd6f9
8 changed files with 188 additions and 205 deletions

View File

@ -2,19 +2,6 @@ package host
import "context"
// ScrobbleList is a list of scrobbles, plus an optional timestamp
// that can be used as a cursor for the next fetch
type ScrobbleList struct {
// The scrobbles in a given range
Scrobbles []ScrobbleRef `json:"scrobbles"`
// If additional items are available, the timestamp of the next scrobble to fetch
NextTimestamp *int64 `json:"nextTimestamp,omitempty"`
// In the event that multiple scrobbles were submitted at the same time, cursor
// will be populated to enable future queries. If this cursor is nonzero
// it should be passed on for the next request
Cursor int `json:"cursor,omitempty"`
}
// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time)
type ScrobbleRef struct {
// The ID of the scrobble. Useful if duplicate scrobbles happen for the same time
@ -35,11 +22,12 @@ type ScrobbleOptions struct {
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
// If true, return scrobbles from newest to oldest. Defaults to oldest first
Descending bool `json:"descending"`
// A cursor retrieved from a prior call to GetScrobbles
Cursor int `json:"cursor,omitempty"`
// The maximum number of items to retrieve. This is capped at 5000, the
// default if not specified
MaxItems int `json:"maxItems"`
// How many scrobbles to skip. GetScrobbles sets this on the options it returns for
// the next page; plugins never need to set it themselves
Offset int `json:"offset,omitempty"`
}
// ScrobbleCountOptions carries optional parameters for counting user scrobbles
@ -68,22 +56,24 @@ type ScrobbleRetrieverService interface {
//nd:hostfunc
GetLastTimestamp(ctx context.Context, username string) (*int64, error)
// GetScrobbles returns scrobbles for a user.
// GetScrobbles returns one page of scrobbles for a user.
//
// Parameters:
// - username: the user to query for scrobbles
// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble
// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble
// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest
// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
// - options.Offset: How many scrobbles to skip. Comes pre-set on the options returned by a previous call
//
// Returns:
// - Scrobbles: A list of scrobbles within the constraints given (if any), ordered by
// submission time (ties broken by scrobble ID) in the direction given by
// options.Descending
// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
// of the next scrobble that would be retrieved in the order (asc or desc)
// - scrobbles: The scrobbles in the requested range, ordered by submission time
// (ties broken by scrobble ID) in the direction given by options.Descending
// - next: The options for the following page, or nil once no scrobbles remain.
// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an
// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range
//nd:hostfunc
GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (*ScrobbleList, error)
GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (scrobbles []ScrobbleRef, next *ScrobbleOptions, err error)
// GetScrobbleCount returns the number of scrobbles for a user in a given range
//

View File

@ -39,8 +39,9 @@ type ScrobbleRetrieverGetScrobblesRequest struct {
// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles.
type ScrobbleRetrieverGetScrobblesResponse struct {
Result *ScrobbleList `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"`
Next *ScrobbleOptions `json:"next,omitempty"`
Error string `json:"error,omitempty"`
}
// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount.
@ -151,7 +152,7 @@ func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverServi
}
// Call the service method
result, svcErr := service.GetScrobbles(ctx, req.Username, req.Options)
scrobbles, next, svcErr := service.GetScrobbles(ctx, req.Username, req.Options)
if svcErr != nil {
scrobbleretrieverWriteError(p, stack, svcErr)
return
@ -159,7 +160,8 @@ func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverServi
// Write JSON response to plugin memory
resp := ScrobbleRetrieverGetScrobblesResponse{
Result: result,
Scrobbles: scrobbles,
Next: next,
}
scrobbleretrieverWriteResponse(p, stack, resp)
},

View File

@ -61,88 +61,79 @@ func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, use
return s.getFirstLastScrobble(ctx, username, "DESC")
}
func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) (*host.ScrobbleList, error) {
func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) ([]host.ScrobbleRef, *host.ScrobbleOptions, error) {
ctx, err := s.getUserContext(ctx, username)
if err != nil {
return nil, err
return nil, nil, err
}
if options.MaxItems < 1 || options.MaxItems > maxScrobbleItems {
options.MaxItems = maxScrobbleItems
}
if options.Cursor < 0 {
options.Cursor = 0
}
// Fetch one more item than requested. The last item is the next timestamp to fetch
options.MaxItems += 1
options.Offset = max(options.Offset, 0)
order := "ASC"
if options.Descending {
order = "DESC"
}
// Fetch one more item than requested. The last item is the next timestamp to fetch
lookahead := options.MaxItems + 1
scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{
Max: options.MaxItems,
Max: lookahead,
Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp),
// The id tiebreak makes the order of equal timestamps stable, which is what
// lets Cursor skip exactly the ties already returned
// lets Offset skip exactly the ties already returned
Sort: "scrobbles.submission_time, scrobbles.id",
Order: order,
Offset: options.Cursor,
Offset: options.Offset,
})
if err != nil {
return nil, err
return nil, nil, err
}
if len(scrobbles) == 0 {
return &host.ScrobbleList{Scrobbles: []host.ScrobbleRef{}}, nil
}
var next *host.ScrobbleOptions
targetLen := len(scrobbles)
cursor := 0
if len(scrobbles) == lookahead {
nextTimestamp := scrobbles[lookahead-1].SubmissionTime
targetLen = lookahead - 1
var nextTimestamp *int64
var targetLen int
if len(scrobbles) == options.MaxItems {
nextTimestamp = &scrobbles[options.MaxItems-1].SubmissionTime
targetLen = options.MaxItems - 1
lastTimestamp := scrobbles[len(scrobbles)-1].SubmissionTime
for i := len(scrobbles) - 2; i >= 0; i-- {
if scrobbles[i].SubmissionTime != lastTimestamp {
ties := 0
for i := targetLen - 1; i >= 0; i-- {
if scrobbles[i].SubmissionTime != nextTimestamp {
break
}
cursor += 1
ties++
}
// In this case, every scrobble in this query is the same timestamp
// We should continue from the previous cursor
if cursor == len(scrobbles)-1 {
cursor += options.Cursor
// Every scrobble in this page shares the timestamp, so the ties skipped by the
// incoming offset are still ahead of us and must be carried over
if ties == targetLen {
ties += options.Offset
}
} else {
targetLen = len(scrobbles)
advanced := options
advanced.Offset = ties
if options.Descending {
advanced.ToTimestamp = &nextTimestamp
} else {
advanced.FromTimestamp = &nextTimestamp
}
next = &advanced
}
scrobbleRefs := make([]host.ScrobbleRef, targetLen)
for idx := range targetLen {
scrobbleRefs[idx].ID = scrobbles[idx].ID
scrobbleRefs[idx].MediaFileID = scrobbles[idx].MediaFileID
scrobbleRefs[idx].SubmissionTime = scrobbles[idx].SubmissionTime
for idx, scrobble := range scrobbles[:targetLen] {
scrobbleRefs[idx] = host.ScrobbleRef{
ID: scrobble.ID,
MediaFileID: scrobble.MediaFileID,
SubmissionTime: scrobble.SubmissionTime,
}
}
response := host.ScrobbleList{
Scrobbles: scrobbleRefs,
NextTimestamp: nextTimestamp,
Cursor: cursor,
}
return &response, nil
return scrobbleRefs, next, nil
}
func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) {

View File

@ -24,6 +24,12 @@ import (
. "github.com/onsi/gomega"
)
// scrobblePage mirrors what the test plugin emits for the multi-return GetScrobbles
type scrobblePage struct {
Scrobbles []host.ScrobbleRef `json:"scrobbles"`
Next *host.ScrobbleOptions `json:"next"`
}
var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
var (
manager *Manager
@ -35,6 +41,13 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
return &val
}
opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions {
return &host.ScrobbleOptions{
FromTimestamp: from, ToTimestamp: to,
Descending: descending, Offset: offset, MaxItems: maxItems,
}
}
BeforeAll(func() {
ctx := GinkgoT().Context()
@ -200,7 +213,7 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
Expect(output).To(Equal([]byte(`{"scrobbles":[],"nextTimestamp":null,"cursor":0}`)))
Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`)))
})
It("calls get scrobble count", func() {
@ -243,30 +256,26 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
Expect(output).To(Equal([]byte("{\"timestamp\":2}")))
})
DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, timestamp *int64, cursor int) {
DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) {
exit, output, err := instance.Call("call_get_scrobbles", []byte(params))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
var scrobbleList host.ScrobbleList
Expect(json.Unmarshal(output, &scrobbleList)).To(Succeed())
Expect(scrobbleList).To(Equal(host.ScrobbleList{
Scrobbles: scrobbles,
NextTimestamp: timestamp,
Cursor: cursor,
}))
var page scrobblePage
Expect(json.Unmarshal(output, &page)).To(Succeed())
Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next}))
},
Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil, 0),
Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil, 0),
Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil, 0),
Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil, 0),
Entry("calls subset of scrobbles in ascending order, next timestamp", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], p(2), 0),
Entry("calls subset of scrobbles in ascending order, with offset next timestamp", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], p(2), 1),
Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil, 0),
Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil, 0),
Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil, 0),
Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], p(0), 0),
Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], p(2), 1),
Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil),
Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil),
Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil),
Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil),
Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)),
Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)),
Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil),
Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil),
Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil),
Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)),
Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)),
)
DescribeTable("GetScrobblesCount", func(params string, count int) {
@ -305,24 +314,22 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
}
})
type TestScrobbleOptions struct {
Username string `json:"username"`
FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
Descending bool `json:"descending"`
Cursor int `json:"cursor,omitempty"`
MaxItems int `json:"maxItems"`
}
getPage := func(cursor, count int, descending bool) host.ScrobbleList {
getPage := func(o host.ScrobbleOptions) scrobblePage {
GinkgoHelper()
payload, err := json.Marshal(TestScrobbleOptions{
payload, err := json.Marshal(struct {
Username string `json:"username"`
FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
Descending bool `json:"descending"`
Offset int `json:"offset,omitempty"`
MaxItems int `json:"maxItems"`
}{
Username: "adminuser",
FromTimestamp: p(100),
ToTimestamp: p(100),
Descending: descending,
Cursor: cursor,
MaxItems: count,
FromTimestamp: o.FromTimestamp,
ToTimestamp: o.ToTimestamp,
Descending: o.Descending,
Offset: o.Offset,
MaxItems: o.MaxItems,
})
Expect(err).ToNot(HaveOccurred())
@ -330,41 +337,48 @@ var _ = Describe("Scrobble Retriever Host Function", Ordered, func() {
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
var scrobbleList host.ScrobbleList
Expect(json.Unmarshal(output, &scrobbleList)).To(Succeed())
return scrobbleList
var page scrobblePage
Expect(json.Unmarshal(output, &page)).To(Succeed())
return page
}
DescribeTable("Edge cases; duplicate scrobbles", func(cursor, count int, descending bool, scrobbles []host.ScrobbleRef, timestamp *int64, expectedCursor int) {
Expect(getPage(cursor, count, descending)).To(Equal(host.ScrobbleList{
Scrobbles: scrobbles,
NextTimestamp: timestamp,
Cursor: expectedCursor,
}))
// every scrobble here shares timestamp 100, so only Offset advances between pages
tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions {
return *opts(p(100), p(100), descending, offset, maxItems)
}
DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) {
var next *host.ScrobbleOptions
if nextOffset != nil {
next = opts(p(100), p(100), descending, *nextOffset, count)
}
Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next}))
},
Entry("All tracks, in order", 0, 100, false, duplicates, nil, 0),
Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil, 0),
Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], p(100), 1),
Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], p(100), 3),
Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil, 0),
Entry("Ascending order, from the start, continuing cursor", 3, 1, false, duplicates[3:4], p(100), 4),
Entry("start descending", 0, 2, true, duplicatesReversed[:2], p(100), 2),
Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], p(100), 4),
Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil, 0),
Entry("All tracks, in order", 0, 100, false, duplicates, nil),
Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil),
Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)),
Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)),
Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil),
Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)),
Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)),
Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)),
Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil),
)
It("walks every page exactly once when all timestamps collide", func() {
for _, descending := range []bool{false, true} {
var seen []host.ScrobbleRef
page := host.ScrobbleList{}
o := tied(0, 2, descending)
var page scrobblePage
for range 10 {
page = getPage(page.Cursor, 2, descending)
page = getPage(o)
seen = append(seen, page.Scrobbles...)
if page.NextTimestamp == nil {
if page.Next == nil {
break
}
o = *page.Next
}
Expect(page.NextTimestamp).To(BeNil(), "pagination did not terminate")
Expect(page.Next).To(BeNil(), "pagination did not terminate")
if descending {
Expect(seen).To(Equal(duplicatesReversed))
} else {

View File

@ -21,23 +21,14 @@ type ScrobbleCountOptions struct {
ToTimestamp *int64 `json:"toTimestamp"`
}
// ScrobbleList represents the ScrobbleList data structure.
// ScrobbleList is a list of scrobbles, plus an optional timestamp
// that can be used as a cursor for the next fetch
type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
NextTimestamp *int64 `json:"nextTimestamp"`
Cursor int `json:"cursor"`
}
// ScrobbleOptions represents the ScrobbleOptions data structure.
// ScrobbleOptions carries optional parameters for retrieving user scrobbles
type ScrobbleOptions struct {
FromTimestamp *int64 `json:"fromTimestamp"`
ToTimestamp *int64 `json:"toTimestamp"`
Descending bool `json:"descending"`
Cursor int `json:"cursor"`
MaxItems int `json:"maxItems"`
Offset int `json:"offset"`
}
// ScrobbleRef represents the ScrobbleRef data structure.
@ -92,8 +83,9 @@ type scrobbleRetrieverGetScrobblesRequest struct {
}
type scrobbleRetrieverGetScrobblesResponse struct {
Result *ScrobbleList `json:"result,omitempty"`
Error string `json:"error,omitempty"`
Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"`
Next *ScrobbleOptions `json:"next,omitempty"`
Error string `json:"error,omitempty"`
}
type scrobbleRetrieverGetScrobbleCountRequest struct {
@ -179,22 +171,23 @@ func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) {
}
// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function.
// GetScrobbles returns scrobbles for a user.
// GetScrobbles returns one page of scrobbles for a user.
//
// Parameters:
// - username: the user to query for scrobbles
// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble
// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble
// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest
// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
// - options.Offset: How many scrobbles to skip. Comes pre-set on the options returned by a previous call
//
// Returns:
// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
// of the items depends on the options: if ToTimestamp is specified AND
// FromTimestamp is not specified, the order is in descending submission time.
// Otherwise, the scrobbles are returned in ascending submission time.
// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
// of the next scrobble that would be retrieved in the order (asc or desc)
func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) {
// - scrobbles: The scrobbles in the requested range, ordered by submission time
// (ties broken by scrobble ID) in the direction given by options.Descending
// - next: The options for the following page, or nil once no scrobbles remain.
// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an
// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range
func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) {
// Marshal request to JSON
req := scrobbleRetrieverGetScrobblesRequest{
Username: username,
@ -202,7 +195,7 @@ func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*S
}
reqBytes, err := json.Marshal(req)
if err != nil {
return nil, err
return nil, nil, err
}
reqMem := pdk.AllocateBytes(reqBytes)
defer reqMem.Free()
@ -217,15 +210,15 @@ func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*S
// Parse the response
var response scrobbleRetrieverGetScrobblesResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
return nil, nil, err
}
// Convert Error field to Go error
if response.Error != "" {
return nil, errors.New(response.Error)
return nil, nil, errors.New(response.Error)
}
return response.Result, nil
return response.Scrobbles, response.Next, nil
}
// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function.

View File

@ -19,23 +19,14 @@ type ScrobbleCountOptions struct {
ToTimestamp *int64 `json:"toTimestamp"`
}
// ScrobbleList represents the ScrobbleList data structure.
// ScrobbleList is a list of scrobbles, plus an optional timestamp
// that can be used as a cursor for the next fetch
type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
NextTimestamp *int64 `json:"nextTimestamp"`
Cursor int `json:"cursor"`
}
// ScrobbleOptions represents the ScrobbleOptions data structure.
// ScrobbleOptions carries optional parameters for retrieving user scrobbles
type ScrobbleOptions struct {
FromTimestamp *int64 `json:"fromTimestamp"`
ToTimestamp *int64 `json:"toTimestamp"`
Descending bool `json:"descending"`
Cursor int `json:"cursor"`
MaxItems int `json:"maxItems"`
Offset int `json:"offset"`
}
// ScrobbleRef represents the ScrobbleRef data structure.
@ -82,28 +73,29 @@ func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) {
}
// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles.
func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) {
func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) {
args := m.Called(username, options)
return args.Get(0).(*ScrobbleList), args.Error(1)
return args.Get(0).([]ScrobbleRef), args.Get(1).(*ScrobbleOptions), args.Error(2)
}
// ScrobbleRetrieverGetScrobbles delegates to the mock instance.
// GetScrobbles returns scrobbles for a user.
// GetScrobbles returns one page of scrobbles for a user.
//
// Parameters:
// - username: the user to query for scrobbles
// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble
// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble
// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest
// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
// - options.Offset: How many scrobbles to skip. Comes pre-set on the options returned by a previous call
//
// Returns:
// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
// of the items depends on the options: if ToTimestamp is specified AND
// FromTimestamp is not specified, the order is in descending submission time.
// Otherwise, the scrobbles are returned in ascending submission time.
// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
// of the next scrobble that would be retrieved in the order (asc or desc)
func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) {
// - scrobbles: The scrobbles in the requested range, ordered by submission time
// (ties broken by scrobble ID) in the direction given by options.Descending
// - next: The options for the following page, or nil once no scrobbles remain.
// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an
// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range
func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) {
return ScrobbleRetrieverMock.GetScrobbles(username, options)
}

View File

@ -16,18 +16,6 @@ pub struct ScrobbleCountOptions {
pub to_timestamp: Option<i64>,
}
/// ScrobbleList is a list of scrobbles, plus an optional timestamp
/// that can be used as a cursor for the next fetch
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrobbleList {
pub scrobbles: Vec<ScrobbleRef>,
#[serde(default)]
pub next_timestamp: Option<i64>,
#[serde(default)]
pub cursor: i32,
}
/// ScrobbleOptions carries optional parameters for retrieving user scrobbles
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -37,9 +25,9 @@ pub struct ScrobbleOptions {
#[serde(default)]
pub to_timestamp: Option<i64>,
pub descending: bool,
#[serde(default)]
pub cursor: i32,
pub max_items: i32,
#[serde(default)]
pub offset: i32,
}
/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time)
@ -92,7 +80,9 @@ struct ScrobbleRetrieverGetScrobblesRequest {
#[serde(rename_all = "camelCase")]
struct ScrobbleRetrieverGetScrobblesResponse {
#[serde(default)]
result: Option<ScrobbleList>,
scrobbles: Vec<ScrobbleRef>,
#[serde(default)]
next: Option<ScrobbleOptions>,
#[serde(default)]
error: Option<String>,
}
@ -171,32 +161,33 @@ pub fn get_last_timestamp(username: &str) -> Result<Option<i64>, Error> {
Ok(response.0.result)
}
/// GetScrobbles returns scrobbles for a user.
/// GetScrobbles returns one page of scrobbles for a user.
///
/// Parameters:
/// - username: the user to query for scrobbles
/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble
/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble
/// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest
/// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
/// - options.Offset: How many scrobbles to skip. Comes pre-set on the options returned by a previous call
///
/// Returns:
/// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
/// of the items depends on the options: if ToTimestamp is specified AND
/// FromTimestamp is not specified, the order is in descending submission time.
/// Otherwise, the scrobbles are returned in ascending submission time.
/// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
/// of the next scrobble that would be retrieved in the order (asc or desc)
/// - scrobbles: The scrobbles in the requested range, ordered by submission time
/// (ties broken by scrobble ID) in the direction given by options.Descending
/// - next: The options for the following page, or nil once no scrobbles remain.
/// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an
/// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range
///
/// # Arguments
/// * `username` - String parameter.
/// * `options` - ScrobbleOptions parameter.
///
/// # Returns
/// The result value.
/// A tuple of (scrobbles, next).
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<Option<ScrobbleList>, Error> {
pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec<ScrobbleRef>, Option<ScrobbleOptions>), Error> {
let response = unsafe {
scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest {
username: username.to_owned(),
@ -208,7 +199,7 @@ pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<Option<
return Err(Error::msg(err));
}
Ok(response.0.result)
Ok((response.0.scrobbles, response.0.next))
}
/// GetScrobbleCount returns the number of scrobbles for a user in a given range

View File

@ -48,10 +48,16 @@ type TestScrobbleOptions struct {
FromTimestamp *int64 `json:"fromTimestamp,omitempty"`
ToTimestamp *int64 `json:"toTimestamp,omitempty"`
Descending bool `json:"descending"`
Cursor int `json:"cursor,omitempty"`
Offset int `json:"offset,omitempty"`
MaxItems int `json:"maxItems"`
}
// TestScrobblePage mirrors the multi-return shape so tests can assert on both values
type TestScrobblePage struct {
Scrobbles []host.ScrobbleRef `json:"scrobbles"`
Next *host.ScrobbleOptions `json:"next"`
}
//go:wasmexport call_get_scrobbles
func callGetScrobbles() int32 {
var options TestScrobbleOptions
@ -62,12 +68,12 @@ func callGetScrobbles() int32 {
return 1
}
scrobbles, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{
scrobbles, next, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{
FromTimestamp: options.FromTimestamp,
ToTimestamp: options.ToTimestamp,
MaxItems: options.MaxItems,
Descending: options.Descending,
Cursor: options.Cursor,
Offset: options.Offset,
})
if err != nil {
@ -75,7 +81,11 @@ func callGetScrobbles() int32 {
return 1
}
pdk.OutputJSON(scrobbles)
if scrobbles == nil {
scrobbles = []host.ScrobbleRef{}
}
pdk.OutputJSON(TestScrobblePage{Scrobbles: scrobbles, Next: next})
return 0
}