mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
Merge branch 'master' into artwork-blurhash
This commit is contained in:
commit
34c90ab66a
@ -34,11 +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)
|
||||
@ -225,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.
|
||||
|
||||
@ -269,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:
|
||||
@ -308,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 |
|
||||
|---------------------|---------------------------------------------|---------|----------------------|
|
||||
@ -328,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 |
|
||||
|---------------------------|----------------------------------------------|--------|-----------------------------|
|
||||
@ -345,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
|
||||
@ -389,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
|
||||
@ -614,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:**
|
||||
|
||||
@ -746,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).
|
||||
@ -896,6 +970,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
|
||||
@ -931,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 <id\|file.ndp> [-f text\|json]` | Show details for an installed plugin or a `.ndp` package |
|
||||
| `navidrome plugin validate <id\|file.ndp>` | Validate an installed plugin or a `.ndp` package manifest |
|
||||
| `navidrome plugin enable <id>` | Enable a plugin |
|
||||
| `navidrome plugin disable <id>` | Disable a plugin |
|
||||
| `navidrome plugin edit <id>` | Update a plugin's config and/or permissions |
|
||||
| `navidrome plugin rescan` | Re-discover plugins in the plugins folder |
|
||||
|
||||
**`plugin edit` flags:**
|
||||
|
||||
- `--config <json>` / `--config-file <path>` – Set the plugin configuration (`-` reads from stdin)
|
||||
- `--users <list>` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users
|
||||
- `--libraries <list>` / `--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
|
||||
@ -993,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.
|
||||
|
||||
@ -1091,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 |
|
||||
|
||||
---
|
||||
|
||||
@ -1109,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
|
||||
|
||||
---
|
||||
@ -1124,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
|
||||
|
||||
|
||||
89
plugins/host/scrobble_retriever.go
Normal file
89
plugins/host/scrobble_retriever.go
Normal file
@ -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)
|
||||
}
|
||||
230
plugins/host/scrobbleretriever_gen.go
Normal file
230
plugins/host/scrobbleretriever_gen.go
Normal file
@ -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
|
||||
}
|
||||
161
plugins/host_scrobbleretriever.go
Normal file
161
plugins/host_scrobbleretriever.go
Normal file
@ -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)
|
||||
390
plugins/host_scrobbleretriever_test.go
Normal file
390
plugins/host_scrobbleretriever_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.
|
||||
|
||||
266
plugins/pdk/go/host/nd_host_scrobbleretriever.go
Normal file
266
plugins/pdk/go/host/nd_host_scrobbleretriever.go
Normal file
@ -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
|
||||
}
|
||||
136
plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go
Normal file
136
plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
237
plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs
Normal file
237
plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs
Normal file
@ -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<i64>,
|
||||
#[serde(default)]
|
||||
pub to_timestamp: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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<i64>,
|
||||
#[serde(default)]
|
||||
pub to_timestamp: Option<i64>,
|
||||
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<i64>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<ScrobbleRef>,
|
||||
#[serde(default)]
|
||||
next: Option<ScrobbleOptions>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn scrobbleretriever_getfirsttimestamp(input: Json<ScrobbleRetrieverGetFirstTimestampRequest>) -> Json<ScrobbleRetrieverGetFirstTimestampResponse>;
|
||||
fn scrobbleretriever_getlasttimestamp(input: Json<ScrobbleRetrieverGetLastTimestampRequest>) -> Json<ScrobbleRetrieverGetLastTimestampResponse>;
|
||||
fn scrobbleretriever_getscrobbles(input: Json<ScrobbleRetrieverGetScrobblesRequest>) -> Json<ScrobbleRetrieverGetScrobblesResponse>;
|
||||
fn scrobbleretriever_getscrobblecount(input: Json<ScrobbleRetrieverGetScrobbleCountRequest>) -> Json<ScrobbleRetrieverGetScrobbleCountResponse>;
|
||||
}
|
||||
|
||||
/// 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<Option<i64>, 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<Option<i64>, 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<ScrobbleRef>, Option<ScrobbleOptions>), 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<i64, Error> {
|
||||
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)
|
||||
}
|
||||
16
plugins/testdata/test-scrobble-retriever/go.mod
vendored
Normal file
16
plugins/testdata/test-scrobble-retriever/go.mod
vendored
Normal file
@ -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
|
||||
14
plugins/testdata/test-scrobble-retriever/go.sum
vendored
Normal file
14
plugins/testdata/test-scrobble-retriever/go.sum
vendored
Normal file
@ -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=
|
||||
120
plugins/testdata/test-scrobble-retriever/main.go
vendored
Normal file
120
plugins/testdata/test-scrobble-retriever/main.go
vendored
Normal file
@ -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
|
||||
}
|
||||
14
plugins/testdata/test-scrobble-retriever/manifest.json
vendored
Normal file
14
plugins/testdata/test-scrobble-retriever/manifest.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user