From b0c6d2e444607e1bb71cafee9361b2dfe4139927 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:13:29 -0700 Subject: [PATCH] feat(plugins): add scrobbles access to PDK (#5795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial scrobble api * feat: add scrobble retrieval api * address feedback (1) * fix spelling * be explicit about get * add primary key field, update index, remove rowid references * use unix timestamp for input and output * initial api, some testing * add tests, add count retrieval * add docs, test for rejected user * add permission validation for scrobble retriever * chore(plugins): fix typos in scrobble retriever Rename newScrobbleRetreverService, and fix FromTImestamp/nonero in the ScrobbleRetriever doc comments, which generate into the Go and Rust PDKs. Also corrects two mislabelled test entries. * fix(plugins): make scrobble pagination order deterministic Sorting only by submission_time left the order of equal timestamps up to the query planner, but the cursor skips ties by offset, so an unstable order can repeat or drop scrobbles between pages. Break ties on scrobbles.id, which the existing scrobbles_user_time index already yields for free. Descending is now honoured for every combination of From/To rather than only when both or neither is set. This changes the default for a lone ToTimestamp from newest-first to oldest-first. * 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. * docs(plugins): warn against setting ScrobbleOptions.Offset manually The all-ties carry rule assumes Offset counts already-returned rows at the boundary timestamp, which only holds for the options GetScrobbles returns. A hand-built From+Offset combination can silently skip scrobbles, so document the field as managed pagination state instead of a generic skip. * docs(plugins): document the ScrobbleRetriever host service in the README Covers the manifest permissions (including the users requirement), the four host functions, the options/ref field tables, and the pagination loop with its two gotchas: the host-managed offset and the adjusted range on the returned next options. * chore(plugins): regenerate scrobble retriever stub with nil-safe accessors --------- Co-authored-by: Deluan Quintão --- plugins/README.md | 77 ++++ plugins/host/scrobble_retriever.go | 89 ++++ plugins/host/scrobbleretriever_gen.go | 230 +++++++++++ plugins/host_scrobbleretriever.go | 161 ++++++++ plugins/host_scrobbleretriever_test.go | 390 ++++++++++++++++++ plugins/manager_loader.go | 8 + plugins/manifest-schema.json | 14 + plugins/manifest.go | 8 +- plugins/manifest_gen.go | 9 + plugins/manifest_test.go | 30 ++ plugins/pdk/go/host/doc.go | 1 + .../pdk/go/host/nd_host_scrobbleretriever.go | 266 ++++++++++++ .../go/host/nd_host_scrobbleretriever_stub.go | 136 ++++++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../src/nd_host_scrobbleretriever.rs | 237 +++++++++++ .../testdata/test-scrobble-retriever/go.mod | 16 + .../testdata/test-scrobble-retriever/go.sum | 14 + .../testdata/test-scrobble-retriever/main.go | 120 ++++++ .../test-scrobble-retriever/manifest.json | 14 + 19 files changed, 1826 insertions(+), 2 deletions(-) create mode 100644 plugins/host/scrobble_retriever.go create mode 100644 plugins/host/scrobbleretriever_gen.go create mode 100644 plugins/host_scrobbleretriever.go create mode 100644 plugins/host_scrobbleretriever_test.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs create mode 100644 plugins/testdata/test-scrobble-retriever/go.mod create mode 100644 plugins/testdata/test-scrobble-retriever/go.sum create mode 100644 plugins/testdata/test-scrobble-retriever/main.go create mode 100644 plugins/testdata/test-scrobble-retriever/manifest.json diff --git a/plugins/README.md b/plugins/README.md index a2f9532ca..adc177324 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -38,6 +38,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) + - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) - [Building Plugins](#building-plugins) - [Examples](#examples) @@ -896,6 +897,82 @@ for _, user := range users { admins, err := host.UsersGetAdmins() ``` +### ScrobbleRetriever + +Retrieve the scrobble history of users the plugin has been granted access to. Each scrobble carries only the media file ID and the submission time; use the Matcher host service to resolve them to track metadata. + +**Manifest permission:** + +```json +{ + "permissions": { + "scrobbleRetriever": { + "reason": "Sync scrobble history to an external service" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +} +``` + +> **Important:** The `scrobbleRetriever` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. + +**Host functions:** + +| Function | Parameters | Returns | +|---------------------------------------|-----------------------|----------------------------------| +| `scrobbleretriever_getfirsttimestamp` | `username` | Unix timestamp of oldest scrobble, or null | +| `scrobbleretriever_getlasttimestamp` | `username` | Unix timestamp of newest scrobble, or null | +| `scrobbleretriever_getscrobbles` | `username`, `options` | One page of scrobbles + options for the next page | +| `scrobbleretriever_getscrobblecount` | `username`, `options` | Number of scrobbles in the range | + +**ScrobbleOptions fields** (all optional): + +| Field | Type | Description | +|-----------------|---------|----------------------------------------------------------| +| `fromTimestamp` | int64 | Start of the range (inclusive). Default: first scrobble | +| `toTimestamp` | int64 | End of the range (inclusive). Default: last scrobble | +| `descending` | boolean | Newest first. Default: oldest first | +| `maxItems` | int | Page size, capped at 5000 (the default) | +| `offset` | int | Managed by the host for pagination. Never set it manually | + +**ScrobbleRef fields:** + +| Field | Type | Description | +|------------------|--------|------------------------------------------------| +| `id` | int64 | Scrobble ID, unique even for duplicate submissions | +| `mediaFileId` | string | The media file that was scrobbled | +| `submissionTime` | int64 | Unix timestamp of the submission | + +**Usage:** + +`GetScrobbles` returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil: + +```go +opts := host.ScrobbleOptions{MaxItems: 500} +var all []host.ScrobbleRef +for { + page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts) + if err != nil { + return err + } + all = append(all, page...) + if next == nil { + break // no more scrobbles + } + opts = *next +} + +// Range boundaries and counts +first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles +count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{ + FromTimestamp: first, +}) +``` + +> **Note:** The returned `next` options carry an adjusted `fromTimestamp`/`toTimestamp`, so keep a copy of your original options if you still need the range. + --- ## Configuration diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go new file mode 100644 index 000000000..2582dd650 --- /dev/null +++ b/plugins/host/scrobble_retriever.go @@ -0,0 +1,89 @@ +package host + +import "context" + +// 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 + ID int64 `json:"id"` + // The ID of the MediaFile submitted at this time + MediaFileID string `json:"mediaFileId"` + // The UNIX timestamp this scrobble was submitted + SubmissionTime int64 `json:"submissionTime"` +} + +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + // If true, return scrobbles from newest to oldest. Defaults to oldest first + Descending bool `json:"descending"` + // The maximum number of items to retrieve. This is capped at 5000, the + // default if not specified + MaxItems int `json:"maxItems"` + // Pagination state managed by GetScrobbles; only meaningful on the options it returns. + // Never set it or combine it with your own timestamps — scrobbles may be silently skipped + Offset int `json:"offset,omitempty"` +} + +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +// ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. +// It will only provide the media_file ID and submission time, which can be combined with the MatcherService +// to fetch deduped tracks +// +//nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever +type ScrobbleRetrieverService interface { + // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. + // If the user has no scrobbles, returns nil + //nd:hostfunc + GetFirstTimestamp(ctx context.Context, username string) (*int64, error) + + // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user + // If the user has no scrobbles, return nil + //nd:hostfunc + GetLastTimestamp(ctx context.Context, username string) (*int64, error) + + // 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: Pagination state; only valid as received on the options returned by a previous call. Never set it manually + // + // Returns: + // - 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) (scrobbles []ScrobbleRef, next *ScrobbleOptions, err error) + + // GetScrobbleCount returns the number of scrobbles for a user in a given range + // + // 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 + // + // Returns: + // - the number of scrobbles in the given range, or 0 + //nd:hostfunc + GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) +} diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go new file mode 100644 index 000000000..7a00c9c1f --- /dev/null +++ b/plugins/host/scrobbleretriever_gen.go @@ -0,0 +1,230 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetFirstTimestampResponse is the response type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesResponse struct { + Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` + Next *ScrobbleOptions `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobbleCountResponse is the response type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { + return []extism.HostFunction{ + newScrobbleRetrieverGetFirstTimestampHostFunction(service), + newScrobbleRetrieverGetLastTimestampHostFunction(service), + newScrobbleRetrieverGetScrobblesHostFunction(service), + newScrobbleRetrieverGetScrobbleCountHostFunction(service), + } +} + +func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getfirsttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetFirstTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetFirstTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetFirstTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getlasttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetLastTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetLastTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetLastTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobbles", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobblesRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + scrobbles, next, svcErr := service.GetScrobbles(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobblesResponse{ + Scrobbles: scrobbles, + Next: next, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobbleCountHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobblecount", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobbleCountRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetScrobbleCount(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobbleCountResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// scrobbleretrieverWriteResponse writes a JSON response to plugin memory. +func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// scrobbleretrieverWriteError writes an error response to plugin memory. +func scrobbleretrieverWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go new file mode 100644 index 000000000..7417d7c50 --- /dev/null +++ b/plugins/host_scrobbleretriever.go @@ -0,0 +1,161 @@ +package plugins + +import ( + "context" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" +) + +// maxScrobbleItems caps how many scrobbles a single GetScrobbles call can return. +const maxScrobbleItems = 5000 + +type scrobbleRetrieverServiceImpl struct { + ds model.DataStore + users userAccess +} + +func newScrobbleRetrieverService(ds model.DataStore, users userAccess) host.ScrobbleRetrieverService { + return &scrobbleRetrieverServiceImpl{ + ds: ds, + users: users, + } +} + +func (s *scrobbleRetrieverServiceImpl) getUserContext(ctx context.Context, username string) (context.Context, error) { + usr, err := s.users.resolve(ctx, s.ds, username) + if err != nil { + return nil, fmt.Errorf("scrobbleRetriever: %w", err) + } + + ctx = request.WithUser(ctx, *usr) + return ctx, nil +} + +func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, username string, order string) (*int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, err + } + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) + if err != nil { + return nil, err + } + + if len(scrobbles) == 0 { + return nil, nil + } + + return &scrobbles[0].SubmissionTime, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetFirstTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "ASC") +} + +func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "DESC") +} + +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, nil, err + } + + if options.MaxItems < 1 || options.MaxItems > maxScrobbleItems { + options.MaxItems = maxScrobbleItems + } + 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: lookahead, + Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), + // The id tiebreak makes the order of equal timestamps stable, which is what + // lets Offset skip exactly the ties already returned + Sort: "scrobbles.submission_time, scrobbles.id", + Order: order, + Offset: options.Offset, + }) + if err != nil { + return nil, nil, err + } + + var next *host.ScrobbleOptions + targetLen := len(scrobbles) + + if len(scrobbles) == lookahead { + nextTimestamp := scrobbles[lookahead-1].SubmissionTime + targetLen = lookahead - 1 + + ties := 0 + for i := targetLen - 1; i >= 0; i-- { + if scrobbles[i].SubmissionTime != nextTimestamp { + break + } + ties++ + } + + // 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 + } + + advanced := options + advanced.Offset = ties + if options.Descending { + advanced.ToTimestamp = &nextTimestamp + } else { + advanced.FromTimestamp = &nextTimestamp + } + next = &advanced + } + + scrobbleRefs := make([]host.ScrobbleRef, targetLen) + for idx, scrobble := range scrobbles[:targetLen] { + scrobbleRefs[idx] = host.ScrobbleRef{ + ID: scrobble.ID, + MediaFileID: scrobble.MediaFileID, + SubmissionTime: scrobble.SubmissionTime, + } + } + + return scrobbleRefs, next, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return 0, err + } + + return s.ds.Scrobble(ctx).CountAll(model.QueryOptions{ + Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), + }) +} + +func scrobbleRangeFilters(from, to *int64) squirrel.And { + var filters squirrel.And + if from != nil { + filters = append(filters, squirrel.GtOrEq{"scrobbles.submission_time": *from}) + } + if to != nil { + filters = append(filters, squirrel.LtOrEq{"scrobbles.submission_time": *to}) + } + return filters +} + +var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go new file mode 100644 index 000000000..ab817c282 --- /dev/null +++ b/plugins/host_scrobbleretriever_test.go @@ -0,0 +1,390 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "time" + + extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "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 + tmpDir string + dataStore *tests.MockDataStore + ) + + p := func(val int64) *int64 { + 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() + + var err error + tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*") + Expect(err).ToNot(HaveOccurred()) + + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + userRepo := dataStore.User(ctx) + // Add test users + _ = userRepo.Put(&model.User{ + ID: "user1", + UserName: "testuser", + IsAdmin: false, + }) + _ = userRepo.Put(&model.User{ + ID: "admin1", + UserName: "adminuser", + IsAdmin: true, + }) + + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "1", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "2", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "3", LibraryID: 1}) + Expect(err).To(BeNil()) + + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + err = scrobbleRepo.RecordScrobble("1", time.Unix(0, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("2", time.Unix(1, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("3", time.Unix(2, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("1", time.Unix(2, 0)) + Expect(err).To(BeNil()) + + // Create and configure manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + } + router := &fakeSubsonicRouter{} + manager.SetSubsonicRouter(router) + + // Pre-enable the plugin in the mock repo so it loads on startup + // Compute SHA256 of the plugin file to match what syncPlugins will compute + pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + dataStore.MockedPlugin = tests.CreateMockPluginRepo() + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + enabledPlugin := model.Plugin{ + ID: "test-scrobble-retriever", + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + Users: `["user1","admin1"]`, + } + mockPluginRepo.SetData(model.Plugins{enabledPlugin}) + + // Start the manager + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + var instance *extism.Plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin := manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + + var err error + ctx := GinkgoT().Context() + instance, err = plugin.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + instance.Close(ctx) + }) + }) + + Describe("not authorized", func() { + It("rejects first timestamp", func() { + exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects last timestamp", func() { + exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + }) + + Describe("no items", func() { + It("calls get first timestamp", func() { + exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls get last timestamp", func() { + exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls scrobbles", func() { + exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`))) + }) + + It("calls get scrobble count", func() { + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + Expect(output).To(Equal([]byte("0"))) + }) + }) + + Describe("with items", func() { + scrobbles := []host.ScrobbleRef{ + {ID: 1, MediaFileID: "1", SubmissionTime: 0}, + {ID: 2, MediaFileID: "2", SubmissionTime: 1}, + {ID: 3, MediaFileID: "3", SubmissionTime: 2}, + {ID: 4, MediaFileID: "1", SubmissionTime: 2}, + } + + scrobblesReversed := make([]host.ScrobbleRef, 4) + + BeforeAll(func() { + for idx := range scrobbles { + scrobblesReversed[3-idx] = scrobbles[idx] + } + }) + + It("calls get first timestamp", func() { + exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":0}"))) + }) + + It("calls get last timestamp", func() { + exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":2}"))) + }) + + 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 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), + 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) { + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + value, err := strconv.ParseInt(string(output), 10, 64) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(int64(count))) + }, + Entry("gets all scrobbles", `{"username":"adminuser"}`, 4), + Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2), + Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1), + Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1), + Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4), + ) + }) + + Context("Complex edge cases - multiple scrobbles at the same timestamp", func() { + duplicates := make([]host.ScrobbleRef, 5) + duplicatesReversed := make([]host.ScrobbleRef, 5) + + BeforeAll(func() { + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + + for i := range 5 { + err := scrobbleRepo.RecordScrobble("3", time.Unix(100, 0)) + Expect(err).To(BeNil()) + + scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100} + duplicates[i] = scrobble + duplicatesReversed[4-i] = scrobble + } + }) + + getPage := func(o host.ScrobbleOptions) scrobblePage { + GinkgoHelper() + 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: o.FromTimestamp, + ToTimestamp: o.ToTimestamp, + Descending: o.Descending, + Offset: o.Offset, + MaxItems: o.MaxItems, + }) + Expect(err).ToNot(HaveOccurred()) + + exit, output, err := instance.Call("call_get_scrobbles", payload) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + var page scrobblePage + Expect(json.Unmarshal(output, &page)).To(Succeed()) + return page + } + + // 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), + 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 + o := tied(0, 2, descending) + var page scrobblePage + for range 10 { + page = getPage(o) + seen = append(seen, page.Scrobbles...) + if page.Next == nil { + break + } + o = *page.Next + } + Expect(page.Next).To(BeNil(), "pagination did not terminate") + if descending { + Expect(seen).To(Equal(duplicatesReversed)) + } else { + Expect(seen).To(Equal(duplicates)) + } + } + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 0e9e7bb62..439c944f5 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -177,6 +177,14 @@ var hostServices = []hostServiceEntry{ return host.RegisterStorageHostFunctions(service), nil, nil }, }, + { + name: "ScrobbleRetriever", + hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + service := newScrobbleRetrieverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) + return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 736677313..dae0fd937 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -95,6 +95,9 @@ }, "storage": { "$ref": "#/$defs/StoragePermission" + }, + "scrobbleRetriever": { + "$ref": "#/$defs/ScrobbleRetrieverPermission" } } }, @@ -258,6 +261,17 @@ "description": "Explanation for why storage access is needed" } } + }, + "ScrobbleRetrieverPermission": { + "type": "object", + "description": "Scrobble retriever permissions for retrieving scrobbles from users", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why scrobble retriever access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index ef70e8d9d..5c0d91f51 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -53,10 +53,14 @@ func ParseManifest(data []byte) (*Manifest, error) { // This validates rules like "SubsonicAPI permission requires users permission". func (m *Manifest) Validate() error { // SubsonicAPI permission requires users permission - if m.Permissions != nil && m.Permissions.Subsonicapi != nil { - if m.Permissions.Users == nil { + if m.Permissions != nil && m.Permissions.Users == nil { + if m.Permissions.Subsonicapi != nil { return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") } + + if m.Permissions.ScrobbleRetriever != nil { + return fmt.Errorf("'scrobbleRetriever' permission requires 'users' permission to be declared") + } } // Matcher returns library content, so it requires the library permission (which diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 5e0035bd5..6f5c01596 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -178,6 +178,9 @@ type Permissions struct { // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + // ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever". + ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"` + // Storage corresponds to the JSON schema field "storage". Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` @@ -200,6 +203,12 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Scrobble retriever permissions for retrieving scrobbles from users +type ScrobbleRetrieverPermission struct { + // Explanation for why scrobble retriever access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Storage permissions for enabling persistent read-write storage exclusively for // the plugin type StoragePermission struct { diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 32bcbba08..bfd8bcac1 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -222,6 +222,36 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("library")) }) + It("validates manifest with scrobbleRetriever and users permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + Users: &UsersPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when scrobbleRetriever without users permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("scrobbleRetriever")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index e4157e29c..9063f2bd0 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. + - ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users. - Storage: provides access to a plugin-specific directory with read/write permissions - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go new file mode 100644 index 000000000..596d7ec1d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -0,0 +1,266 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + +// 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"` + MaxItems int `json:"maxItems"` + Offset int `json:"offset"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp +func scrobbleretriever_getfirsttimestamp(uint64) uint64 + +// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp +func scrobbleretriever_getlasttimestamp(uint64) uint64 + +// scrobbleretriever_getscrobbles is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobbles +func scrobbleretriever_getscrobbles(uint64) uint64 + +// scrobbleretriever_getscrobblecount is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobblecount +func scrobbleretriever_getscrobblecount(uint64) uint64 + +type scrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobblesResponse struct { + Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` + Next *ScrobbleOptions `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetFirstTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getfirsttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetFirstTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetLastTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetLastTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. +// 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: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +// +// Returns: +// - 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, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobbles(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobblesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, nil, errors.New(response.Error) + } + + return response.Scrobbles, response.Next, nil +} + +// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// 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 +// +// Returns: +// - the number of scrobbles in the given range, or 0 +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobbleCountRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobbleCountResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go new file mode 100644 index 000000000..a75fcebb5 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/stretchr/testify/mock" +) + +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + +// 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"` + MaxItems int `json:"maxItems"` + Offset int `json:"offset"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// mockScrobbleRetrieverService is the mock implementation for testing. +type mockScrobbleRetrieverService struct { + mock.Mock +} + +// ScrobbleRetrieverMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) +var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} + +// GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. +func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { + args := m.Called(username) + var r0 *int64 + if v := args.Get(0); v != nil { + r0 = v.(*int64) + } + return r0, args.Error(1) +} + +// ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetFirstTimestamp(username) +} + +// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. +func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { + args := m.Called(username) + var r0 *int64 + if v := args.Get(0); v != nil { + r0 = v.(*int64) + } + return r0, args.Error(1) +} + +// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetLastTimestamp(username) +} + +// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. +func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + args := m.Called(username, options) + var r0 []ScrobbleRef + if v := args.Get(0); v != nil { + r0 = v.([]ScrobbleRef) + } + var r1 *ScrobbleOptions + if v := args.Get(1); v != nil { + r1 = v.(*ScrobbleOptions) + } + return r0, r1, args.Error(2) +} + +// ScrobbleRetrieverGetScrobbles delegates to the mock instance. +// 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: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +// +// Returns: +// - 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) +} + +// GetScrobbleCount is the mock method for ScrobbleRetrieverGetScrobbleCount. +func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + args := m.Called(username, options) + return args.Get(0).(int64), args.Error(1) +} + +// ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// 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 +// +// Returns: +// - the number of scrobbles in the given range, or 0 +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + return ScrobbleRetrieverMock.GetScrobbleCount(username, options) +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3da023f5c..33cbe7725 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users. //! - [`storage`] - provides access to a plugin-specific directory with read/write permissions //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -102,6 +103,13 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } +#[doc(hidden)] +mod nd_host_scrobbleretriever; +/// allows a plugin to retrieve scrobbles for one or more authorized users. +pub mod scrobbleretriever { + pub use super::nd_host_scrobbleretriever::*; +} + #[doc(hidden)] mod nd_host_storage; /// provides access to a plugin-specific directory with read/write permissions 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 new file mode 100644 index 000000000..fe80d98f6 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -0,0 +1,237 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// ScrobbleCountOptions carries optional parameters for counting user scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleCountOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, +} + +/// ScrobbleOptions carries optional parameters for retrieving user scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, + pub descending: bool, + pub max_items: i32, + #[serde(default)] + pub offset: i32, +} + +/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleRef { + pub id: i64, + pub media_file_id: String, + pub submission_time: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesRequest { + username: String, + options: ScrobbleOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesResponse { + #[serde(default)] + scrobbles: Vec, + #[serde(default)] + next: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountRequest { + username: String, + options: ScrobbleCountOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountResponse { + #[serde(default)] + result: i64, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; + fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; + fn scrobbleretriever_getscrobbles(input: Json) -> Json; + fn scrobbleretriever_getscrobblecount(input: Json) -> Json; +} + +/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +/// If the user has no scrobbles, returns nil +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_first_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getfirsttimestamp(Json(ScrobbleRetrieverGetFirstTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// If the user has no scrobbles, return nil +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_last_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// 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: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +/// +/// Returns: +/// - 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 +/// A tuple of (scrobbles, next). +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec, Option), Error> { + let response = unsafe { + scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok((response.0.scrobbles, response.0.next)) +} + +/// GetScrobbleCount returns the number of scrobbles for a user in a given range +/// +/// 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 +/// +/// Returns: +/// - the number of scrobbles in the given range, or 0 +/// +/// # Arguments +/// * `username` - String parameter. +/// * `options` - ScrobbleCountOptions parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobble_count(username: &str, options: ScrobbleCountOptions) -> Result { + let response = unsafe { + scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod new file mode 100644 index 000000000..59486d796 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/go.mod @@ -0,0 +1,16 @@ +module test-scrobble-retriever + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-scrobble-retriever/go.sum b/plugins/testdata/test-scrobble-retriever/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go new file mode 100644 index 000000000..cc824acb9 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +func main() { + +} + +type TestScrobbleTimestampOutput struct { + Timestamp *int64 `json:"timestamp"` +} + +//go:wasmexport call_get_first_timestamp +func callGetFirstTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetFirstTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +//go:wasmexport call_get_last_timestamp +func callGetLastTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetLastTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +type TestScrobbleOptions 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"` +} + +// 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 + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + scrobbles, next, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + MaxItems: options.MaxItems, + Descending: options.Descending, + Offset: options.Offset, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + if scrobbles == nil { + scrobbles = []host.ScrobbleRef{} + } + + pdk.OutputJSON(TestScrobblePage{Scrobbles: scrobbles, Next: next}) + return 0 +} + +type TestScrobbleCountOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +//go:wasmexport call_get_scrobbles_count +func callGetScrobblesCount() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + count, err := host.ScrobbleRetrieverGetScrobbleCount(options.Username, host.ScrobbleCountOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputString(strconv.FormatInt(count, 10)) + return 0 +} diff --git a/plugins/testdata/test-scrobble-retriever/manifest.json b/plugins/testdata/test-scrobble-retriever/manifest.json new file mode 100644 index 000000000..a60203704 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Scrobble Retriever", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for scrobble retriever integration settings", + "permissions": { + "scrobbleRetriever": { + "reason": "For testing scrobble retriever operations" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +}