From f49b8bd6f9f70ca3f4ca90643f03a3825f49f8e8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 8 Aug 2026 14:11:09 -0400 Subject: [PATCH] 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. --- plugins/host/scrobble_retriever.go | 34 ++--- plugins/host/scrobbleretriever_gen.go | 10 +- plugins/host_scrobbleretriever.go | 87 ++++++------ plugins/host_scrobbleretriever_test.go | 130 ++++++++++-------- .../pdk/go/host/nd_host_scrobbleretriever.go | 41 +++--- .../go/host/nd_host_scrobbleretriever_stub.go | 32 ++--- .../src/nd_host_scrobbleretriever.rs | 41 +++--- .../testdata/test-scrobble-retriever/main.go | 18 ++- 8 files changed, 188 insertions(+), 205 deletions(-) diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go index f953c2baf..b1bb1e19c 100644 --- a/plugins/host/scrobble_retriever.go +++ b/plugins/host/scrobble_retriever.go @@ -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 // diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go index 411826f76..7a00c9c1f 100644 --- a/plugins/host/scrobbleretriever_gen.go +++ b/plugins/host/scrobbleretriever_gen.go @@ -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) }, diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go index b405db4ae..7417d7c50 100644 --- a/plugins/host_scrobbleretriever.go +++ b/plugins/host_scrobbleretriever.go @@ -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) { diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index 6a51ff1ad..ab817c282 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -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 { diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go index 5c6794cc2..7b6b283c0 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -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. diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go index 674d381f1..2a5bb10fb 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -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) } diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs index e6245a649..b64201748 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -16,18 +16,6 @@ pub struct ScrobbleCountOptions { pub to_timestamp: Option, } -/// 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, - #[serde(default)] - pub next_timestamp: Option, - #[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, 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, + scrobbles: Vec, + #[serde(default)] + next: Option, #[serde(default)] error: Option, } @@ -171,32 +161,33 @@ pub fn get_last_timestamp(username: &str) -> Result, 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, Error> { +pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec, Option), 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