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 1/2] 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" + } + } +} From 5c5b849a4e6133eb7fc7286007ee2260f0aa3638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 8 Aug 2026 22:25:48 -0400 Subject: [PATCH 2/2] docs(plugins): sync plugins README with the implementation (#5912) * docs(plugins): document the Matcher host service The Matcher host service (matcher_matchsongs, permission 'matcher') was missing from the README entirely. Add its section with permission example, the matcher-requires-library rule, result ordering/scoping semantics, and a Go usage example. * docs(plugins): add missing nd_scrobbler_playback_report to Scrobbler The Scrobbler capability has four required methods, but the README said three and omitted nd_scrobbler_playback_report from the function table. Add the table row and document the PlaybackReport input payload and its possible playback states. * docs(plugins): remove stale coverartarchive-as example row The AssemblyScript coverartarchive-as example no longer exists in plugins/examples/ (and no AssemblyScript code remains in the repo), so drop its row from the examples table. * docs(plugins): add Command Line Interface section The 'navidrome plugin' CLI (list, info, validate, enable, disable, edit, rescan) was undocumented; the README's only mention was a single inline 'plugin edit --write-access' reference, and it claimed plugins could only be enabled/disabled via the UI. Add a CLI section with all subcommands and the 'plugin edit' flags, and link it from Runtime Management. * docs(plugins): add pdk and types packages to Go PDK table The Go PDK package table was missing two of the packages that ship under plugins/pdk/go/: 'types' (shared DTOs used across capabilities and host services) and 'pdk' (the extism/go-pdk wrapper the README's own examples already import). * docs(plugins): correct capabilities and host services in examples table Several example rows understated what the plugins implement: the scheduler/websocket callback capabilities were omitted for nowplaying-py, library-inspector-rs, crypto-ticker, and discord-rich-presence-rs, and the Discord example also uses the Config host service. * docs(plugins): state that scheduler/taskqueue permissions require callbacks Manifest validation rejects a plugin that declares the scheduler or taskqueue permission without exporting nd_scheduler_callback or nd_task_execute, but the README worded both callbacks as soft suggestions (TaskWorker was even labeled optional). Make the load-time failure explicit in both capability sections. * docs(plugins): document HTTP, WebSocket, and Task limits and defaults Add the runtime limits plugins actually hit: HTTP default 10s timeout, 5-redirect cap with per-hop host re-validation, and 10MB response cap; the 30s timeout on WebSocket callbacks; and the full task_createqueue parameter list (delayMs, retentionMs) with queue defaults, retention bounds, the 1MB payload cap, and task persistence across restarts. * docs(plugins): include scrobbleRetriever in user-scoped authorization note The ScrobbleRetriever host service added in #5795 is user-scoped like subsonicapi and scrobbler, but the Security section's user-scoped authorization item didn't list it. * docs(plugins): clarify matcher user scoping requires the users permission The Matcher usage example set opts.Username while the section's manifest example declares only matcher+library; without the users permission no users can be assigned to the plugin, so userAccess.resolve rejects the call at runtime. Make the example unscoped and state that user scoping requires the users permission with users assigned. Raised by Codex review on PR #5912. --- plugins/README.md | 143 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 23 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index adc177324..b04e12bd9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -34,12 +34,14 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Task](#task) - [WebSocket](#websocket) - [Library](#library) + - [Matcher](#matcher) - [Artwork](#artwork) - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) +- [Command Line Interface](#command-line-interface) - [Building Plugins](#building-plugins) - [Examples](#examples) - [Security](#security) @@ -226,13 +228,14 @@ func ndGetArtistBiography() int32 { ### Scrobbler -Integrates with external scrobbling services. All three methods are **required**. +Integrates with external scrobbling services. All four methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|--------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|---------------------------------|--------------|--------|------------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| `nd_scrobbler_playback_report` | See below | (none) | Send playback state report | > **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. @@ -270,6 +273,25 @@ Integrates with external scrobbling services. All three methods are **required** } ``` +**PlaybackReport Input:** + +Same `username` and `track` fields, plus playback state details: + +```json +{ + "username": "john", + "track": { ... }, + "state": "playing", + "positionMs": 45000, + "playbackRate": 1.0, + "playerId": "player-id", + "playerName": "My Client", + "timestamp": 1703270400 +} +``` + +`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`. + **Error Handling:** On success, return `0`. On failure, use `pdk.SetError()` with one of these error types: @@ -309,7 +331,7 @@ Each match contains a `song` reference and a `similarity` score (float64, 0.0– ### TaskWorker -Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. +Processes tasks from a queue. **Required** if your plugin uses the [Task](#task) host service: declaring the `taskqueue` permission without exporting this function fails the plugin load. | Function | Input | Output | Description | |---------------------|---------------------------------------------|---------|----------------------| @@ -329,7 +351,7 @@ Useful for initializing connections, scheduling recurring tasks, etc. Errors are ### SchedulerCallback -Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service: declaring the `scheduler` permission without exporting this function fails the plugin load. | Function | Input | Output | Description | |---------------------------|----------------------------------------------|--------|-----------------------------| @@ -346,6 +368,8 @@ Receives WebSocket events. Export any subset of these to handle events from the | `nd_websocket_on_error` | `{connectionId, error}` | Connection error | | `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +Each callback invocation is subject to a 30-second timeout. + --- ## Host Services @@ -390,6 +414,8 @@ Make HTTP requests to external services. This is a dedicated host service (separ |-------------|----------------------------------------------------------|----------------------------------| | `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | +**Limits:** Requests time out after 10 seconds by default (override per request with `timeoutMs`). Redirects are followed up to 5 times, re-checking the allowed hosts on every hop. Response bodies are capped at 10MB. + **Usage:** ```go @@ -615,13 +641,21 @@ Background task queue with retry support. Plugins enqueue tasks and process them **Host functions:** -| Function | Parameters | Description | -|---------------------|---------------------------------------------------|----------------------------| -| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | -| `task_enqueue` | `queueName, payload` | Add a task to the queue | -| `task_get` | `taskID` | Get task status and result | -| `task_cancel` | `taskID` | Cancel a pending task | -| `task_clearqueue` | `queueName` | Remove all tasks from queue| +| Function | Parameters | Description | +|---------------------|---------------------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, delayMs, retentionMs` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +Tasks are persisted to SQLite, so pending tasks survive server restarts. Queue behavior: + +- `concurrency` – Parallel workers (default 1), capped by the manifest's `maxConcurrency` +- `maxRetries` – Retries for a failed task (default 0); `backoffMs` (default 1000) doubles on each retry +- `delayMs` – Minimum delay between consecutive task starts, useful for rate limiting (default 0) +- `retentionMs` – How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week) +- Payloads are capped at 1MB **Usage:** @@ -747,6 +781,45 @@ for _, lib := range libraries { } ``` +### Matcher + +Match externally-obtained songs (e.g. results from a recommendation or similarity API) to tracks in the local library, reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). + +**Manifest permission:** + +```json +{ + "permissions": { + "matcher": { + "reason": "Resolve external recommendations to library tracks" + }, + "library": { + "reason": "Required by the matcher permission" + } + } +} +``` + +> **Important:** The `matcher` permission requires the `library` permission. + +**Host functions:** + +| Function | Parameters | Returns | +|----------------------|---------------|-------------------------| +| `matcher_matchsongs` | `songs, opts` | Array of matched tracks | + +The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set `opts.username` to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the [`users`](#users) permission, with users assigned to the plugin. + +**Usage:** + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +matches, err := host.MatcherMatchSongs([]types.SongRef{ + {Name: "Song Title", Artists: []types.ArtistRef{{Name: "Artist Name"}}}, +}, host.MatchOptions{}) +``` + ### Artwork Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). @@ -1008,6 +1081,29 @@ For more advanced access (listing keys, integer values), use the [Config](#confi --- +## Command Line Interface + +Manage plugins from the command line with `navidrome plugin`: + +| Command | Description | +|--------------------------------------------------------|------------------------------------------------------------| +| `navidrome plugin list [-f table\|csv\|json]` | List installed plugins | +| `navidrome plugin info [-f text\|json]` | Show details for an installed plugin or a `.ndp` package | +| `navidrome plugin validate ` | Validate an installed plugin or a `.ndp` package manifest | +| `navidrome plugin enable ` | Enable a plugin | +| `navidrome plugin disable ` | Disable a plugin | +| `navidrome plugin edit ` | Update a plugin's config and/or permissions | +| `navidrome plugin rescan` | Re-discover plugins in the plugins folder | + +**`plugin edit` flags:** + +- `--config ` / `--config-file ` – Set the plugin configuration (`-` reads from stdin) +- `--users ` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users +- `--libraries ` / `--all-libraries` – Library IDs the plugin may access (comma-separated or JSON array), or all libraries +- `--write-access` / `--no-write-access` – Allow or deny the plugin write access to libraries + +--- + ## Building Plugins ### Supported Languages @@ -1070,6 +1166,8 @@ replace github.com/navidrome/navidrome => ../../.. | `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | | `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | | `host` | `plugins/pdk/go/host` | Host service SDK (all services) | +| `types` | `plugins/pdk/go/types` | Shared data types (tracks, artists, song refs) | +| `pdk` | `plugins/pdk/go/pdk` | Low-level helpers (wraps extism/go-pdk: config, logging, memory) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -1168,12 +1266,11 @@ See [examples/](examples/) for complete working plugins: | [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | | [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | | [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler, SchedulerCallback, WebSocketCallback | HTTP, WebSocket, Cache, Scheduler, Artwork, Config | Discord integration | --- @@ -1186,7 +1283,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration +6. **User-Scoped Authorization** – Plugins with `subsonicapi`, `scrobbleRetriever`, or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed --- @@ -1201,7 +1298,7 @@ If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin c ### Enabling/Disabling Plugins -Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persisted in the database. +Plugins can be enabled/disabled via the Navidrome UI or the [`navidrome plugin` CLI](#command-line-interface). The plugin state is persisted in the database. ### Important Notes