mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
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:
parent
22d94877fe
commit
f49b8bd6f9
@ -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
|
||||
//
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
18
plugins/testdata/test-scrobble-retriever/main.go
vendored
18
plugins/testdata/test-scrobble-retriever/main.go
vendored
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user