Merge 885eda0674e918353247e2f81116b9560e87e5a3 into d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e

This commit is contained in:
Kendall Garner 2026-07-31 15:55:21 -04:00 committed by GitHub
commit 3559e067f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1658 additions and 2 deletions

View File

@ -0,0 +1,92 @@
package host
import "context"
// ScrobbleList is a list of scrobbles, plus an optional timestamp
// that can be used as a cursor for the next fetch
type ScrobbleList struct {
// The scrobbles in a given range
Scrobbles []ScrobbleRef `json:"scrobbles"`
// If additional items are available, the timestamp of the next scrobble to fetch
NextTimestamp *int64 `json:"nextTimestamp,omitempty"`
}
// 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"`
// The maximum number of items to retrieve. This is capped at 5000, the
// default if not specified
MaxItems int `json:"maxItems"`
}
// 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 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.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
//
// Returns:
// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
// of the items depends on the options: if ToTimestamp is specified AND
// FromTImestamp is not specified, the order is in descending submission time.
// Otherwise, the scrobbles are returned in ascending submission time.
// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
// of the next scrobble that would be retrieved in the order (asc or desc)
//nd:hostfunc
GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (*ScrobbleList, 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)
}

View File

@ -0,0 +1,228 @@
// 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 {
Result *ScrobbleList `json:"result,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
result, svcErr := service.GetScrobbles(ctx, req.Username, req.Options)
if svcErr != nil {
scrobbleretrieverWriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := ScrobbleRetrieverGetScrobblesResponse{
Result: result,
}
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
}

View File

@ -0,0 +1,153 @@
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"
)
type scrobbleRetrieverServiceImpl struct {
ds model.DataStore
users userAccess
}
func newScrobbleRetreverService(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.ScrobbleList, error) {
ctx, err := s.getUserContext(ctx, username)
if err != nil {
return nil, err
}
if options.MaxItems < 1 || options.MaxItems > 5000 {
options.MaxItems = 5000
}
// Fetch one more item than requested. The last item is the next timestamp to fetch
options.MaxItems += 1
var filters squirrel.And
if options.FromTimestamp != nil {
filters = append(filters, squirrel.GtOrEq{"submission_time": *options.FromTimestamp})
}
if options.ToTimestamp != nil {
filters = append(filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp})
}
var order string
if options.ToTimestamp != nil && options.FromTimestamp == nil {
order = "DESC"
} else {
order = "ASC"
}
scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{
Max: options.MaxItems,
Filters: filters,
Sort: "submission_time",
Order: order,
})
if err != nil {
return nil, err
}
var nextTimestamp *int64
var targetLen int
if len(scrobbles) == options.MaxItems {
nextTimestamp = &scrobbles[options.MaxItems-1].SubmissionTime
targetLen = options.MaxItems - 1
} else {
targetLen = len(scrobbles)
}
scrobbleRefs := make([]host.ScrobbleRef, targetLen)
for idx := range targetLen {
scrobbleRefs[idx].ID = scrobbles[idx].ID
scrobbleRefs[idx].MediaFileID = scrobbles[idx].MediaFileID
scrobbleRefs[idx].SubmissionTime = scrobbles[idx].SubmissionTime
}
response := host.ScrobbleList{
Scrobbles: scrobbleRefs,
NextTimestamp: nextTimestamp,
}
return &response, 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
}
var filters squirrel.And
if options.FromTimestamp != nil {
filters = append(filters, squirrel.GtOrEq{"submission_time": *options.FromTimestamp})
}
if options.ToTimestamp != nil {
filters = append(filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp})
}
count, err := s.ds.Scrobble(ctx).CountAll(model.QueryOptions{
Filters: filters,
})
if err != nil {
return 0, err
}
return count, nil
}
var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil)

View File

@ -0,0 +1,323 @@
//go:build !windows
package plugins
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strconv"
"time"
"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"
)
var _ = Describe("Scrobbble Retriever Host Function", Ordered, func() {
var (
manager *Manager
tmpDir string
dataStore *tests.MockDataStore
)
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 plugin *plugin
BeforeEach(func() {
manager.mu.RLock()
plugin = manager.plugins["test-scrobble-retriever"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
})
Describe("not authorized", func() {
It("rejects first timestamp", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects scrobbles", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
})
It("rejects scrobbles", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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":[],"nextTimestamp":null}`)))
})
It("calls get scrobble count", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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() {
p := func(val int64) *int64 {
return &val
}
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() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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, timestamp *int64) {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_get_scrobbles", []byte(params))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
var scrobbleList host.ScrobbleList
Expect(json.Unmarshal(output, &scrobbleList)).To(Succeed())
Expect(scrobbleList).To(Equal(host.ScrobbleList{
Scrobbles: scrobbles,
NextTimestamp: timestamp,
}))
},
Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil),
Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil),
Entry("calls subset of scrobbles in ascending order, next timestamp", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], p(2)),
Entry("calls subset of scrobbles in ascending order, with offset next timestamp", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], p(2)),
Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil),
Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2}`, scrobblesReversed, nil),
Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2, "maxItems": 3}`, scrobblesReversed[:3], p(0)),
Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2, "maxItems": 1}`, scrobblesReversed[:1], p(2)),
)
DescribeTable("GetScrobblesCount", func(params string, count int) {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
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),
)
})
})

View File

@ -166,6 +166,14 @@ var hostServices = []hostServiceEntry{
return host.RegisterTaskHostFunctions(service), service, nil
},
},
{
name: "ScrobbleRetriever",
hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) {
service := newScrobbleRetreverService(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.

View File

@ -92,6 +92,9 @@
},
"matcher": {
"$ref": "#/$defs/MatcherPermission"
},
"scrobbleRetriever": {
"$ref": "#/$defs/ScrobbleRetrieverPermission"
}
}
},
@ -244,6 +247,17 @@
"description": "Explanation for why matcher 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"
}
}
}
}
}

View File

@ -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

View File

@ -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"`
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
@ -197,6 +200,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"`
}
// SubsonicAPI service permissions. Requires 'users' permission to be declared.
type SubsonicAPIPermission struct {
// Explanation for why SubsonicAPI access is needed

View File

@ -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",

View File

@ -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.
- SubsonicAPI: provides access to Navidrome's Subsonic API from plugins.
- Task: provides persistent task queues for plugins.
- Users: provides access to user information for plugins.

View File

@ -0,0 +1,270 @@
// 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"`
}
// ScrobbleList represents the ScrobbleList data structure.
// ScrobbleList is a list of scrobbles, plus an optional timestamp
// that can be used as a cursor for the next fetch
type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
NextTimestamp *int64 `json:"nextTimestamp"`
}
// 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"`
MaxItems int `json:"maxItems"`
}
// 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 {
Result *ScrobbleList `json:"result,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 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.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
//
// Returns:
// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
// of the items depends on the options: if ToTimestamp is specified AND
// FromTImestamp is not specified, the order is in descending submission time.
// Otherwise, the scrobbles are returned in ascending submission time.
// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
// of the next scrobble that would be retrieved in the order (asc or desc)
func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) {
// Marshal request to JSON
req := scrobbleRetrieverGetScrobblesRequest{
Username: username,
Options: options,
}
reqBytes, err := json.Marshal(req)
if err != nil {
return 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, err
}
// Convert Error field to Go error
if response.Error != "" {
return nil, errors.New(response.Error)
}
return response.Result, 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
}

View File

@ -0,0 +1,125 @@
// 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"`
}
// ScrobbleList represents the ScrobbleList data structure.
// ScrobbleList is a list of scrobbles, plus an optional timestamp
// that can be used as a cursor for the next fetch
type ScrobbleList struct {
Scrobbles []ScrobbleRef `json:"scrobbles"`
NextTimestamp *int64 `json:"nextTimestamp"`
}
// 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"`
MaxItems int `json:"maxItems"`
}
// 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)
return args.Get(0).(*int64), 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)
return args.Get(0).(*int64), 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) (*ScrobbleList, error) {
args := m.Called(username, options)
return args.Get(0).(*ScrobbleList), args.Error(1)
}
// ScrobbleRetrieverGetScrobbles delegates to the mock instance.
// GetScrobbles returns 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.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
//
// Returns:
// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
// of the items depends on the options: if ToTimestamp is specified AND
// FromTImestamp is not specified, the order is in descending submission time.
// Otherwise, the scrobbles are returned in ascending submission time.
// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
// of the next scrobble that would be retrieved in the order (asc or desc)
func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) {
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)
}

View File

@ -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.
//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins.
//! - [`task`] - provides persistent task queues for plugins.
//! - [`users`] - provides access to user information for plugins.
@ -101,6 +102,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_subsonicapi;
/// provides access to Navidrome's Subsonic API from plugins.

View File

@ -0,0 +1,241 @@
// 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>,
}
/// ScrobbleList is a list of scrobbles, plus an optional timestamp
/// that can be used as a cursor for the next fetch
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScrobbleList {
pub scrobbles: Vec<ScrobbleRef>,
#[serde(default)]
pub next_timestamp: Option<i64>,
}
/// 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 max_items: 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)]
result: Option<ScrobbleList>,
#[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 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.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000
///
/// Returns:
/// - Scrobbles: A list of scrobbles within the constraints given (if any). The order
/// of the items depends on the options: if ToTimestamp is specified AND
/// FromTImestamp is not specified, the order is in descending submission time.
/// Otherwise, the scrobbles are returned in ascending submission time.
/// - NextTimestamp: If there are additional items to retrieve in the range, the timestamp
/// of the next scrobble that would be retrieved in the order (asc or desc)
///
/// # Arguments
/// * `username` - String parameter.
/// * `options` - ScrobbleOptions parameter.
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<Option<ScrobbleList>, 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.result)
}
/// 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)
}

View 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

View 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=

View File

@ -0,0 +1,106 @@
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"`
MaxItems int `json:"maxItems"`
}
//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, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{
FromTimestamp: options.FromTimestamp,
ToTimestamp: options.ToTimestamp,
MaxItems: options.MaxItems,
})
if err != nil {
pdk.SetErrorString("failed to call scrobble retriever api " + err.Error())
return 1
}
pdk.OutputJSON(scrobbles)
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
}

View 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"
}
}
}