diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go index 06abf59de..569dc8641 100644 --- a/plugins/host/scrobble_retriever.go +++ b/plugins/host/scrobble_retriever.go @@ -19,6 +19,11 @@ type ScrobbleOptions struct { MaxItems int `json:"maxItems"` } +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + 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 @@ -35,4 +40,7 @@ type ScrobbleRetrieverService interface { //nd:hostfunc GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (*ScrobbleList, error) + + //nd:hostfunc + GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) } diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go index f3f3c3906..411826f76 100644 --- a/plugins/host/scrobbleretriever_gen.go +++ b/plugins/host/scrobbleretriever_gen.go @@ -9,17 +9,6 @@ import ( extism "github.com/extism/go-sdk" ) -// 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"` -} - // ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. type ScrobbleRetrieverGetFirstTimestampRequest struct { Username string `json:"username"` @@ -31,6 +20,17 @@ type ScrobbleRetrieverGetFirstTimestampResponse struct { 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"` @@ -43,50 +43,29 @@ type ScrobbleRetrieverGetScrobblesResponse struct { 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{ - newScrobbleRetrieverGetLastTimestampHostFunction(service), newScrobbleRetrieverGetFirstTimestampHostFunction(service), + newScrobbleRetrieverGetLastTimestampHostFunction(service), newScrobbleRetrieverGetScrobblesHostFunction(service), + newScrobbleRetrieverGetScrobbleCountHostFunction(service), } } -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 newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { return extism.NewHostFunctionWithStack( "scrobbleretriever_getfirsttimestamp", @@ -121,6 +100,40 @@ func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetriever ) } +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", @@ -155,6 +168,40 @@ func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverServi ) } +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) diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go index 54ae3456a..db2620bca 100644 --- a/plugins/host_scrobbleretriever.go +++ b/plugins/host_scrobbleretriever.go @@ -64,20 +64,20 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam return nil, err } - if options.MaxItems == 0 { + 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.Sqlizer + var filters squirrel.And if options.FromTimestamp != nil { - filters = squirrel.GtOrEq{"submission_time": *options.FromTimestamp} + filters = append(filters, squirrel.GtOrEq{"submission_time": *options.FromTimestamp}) } if options.ToTimestamp != nil { - filters = squirrel.And{filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp}} + filters = append(filters, squirrel.LtOrEq{"submission_time": *options.ToTimestamp}) } var order string @@ -99,13 +99,18 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam } 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, options.MaxItems-1) - for idx := range scrobbleRefs { + 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 @@ -119,4 +124,30 @@ func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, usernam 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) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go new file mode 100644 index 000000000..8175c7d06 --- /dev/null +++ b/plugins/host_scrobbleretriever_test.go @@ -0,0 +1,290 @@ +//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) + }) + }) + + Describe("no items", func() { + var plugin *plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + 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() { + var plugin *plugin + + 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] + } + }) + + BeforeEach(func() { + manager.mu.RLock() + plugin = manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + }) + + 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), + ) + }) +}) diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go index e1f179822..c73ff5073 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -14,6 +14,12 @@ import ( "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + // ScrobbleList represents the ScrobbleList data structure. type ScrobbleList struct { Scrobbles []ScrobbleRef `json:"scrobbles"` @@ -34,29 +40,25 @@ type ScrobbleRef struct { SubmissionTime int64 `json:"submissionTime"` } -// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp -func scrobbleretriever_getlasttimestamp(uint64) uint64 - // 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 -type scrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -type scrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} +// 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"` @@ -67,6 +69,15 @@ type scrobbleRetrieverGetFirstTimestampResponse struct { 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"` @@ -77,39 +88,14 @@ type scrobbleRetrieverGetScrobblesResponse struct { Error string `json:"error,omitempty"` } -// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -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() +type scrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} - // 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 +type scrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` } // ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. @@ -147,6 +133,41 @@ func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, 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 +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. func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*ScrobbleList, error) { // Marshal request to JSON @@ -181,3 +202,38 @@ func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) (*S return response.Result, nil } + +// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobbleCountRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobbleCountResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go index 8450c171b..12bc46887 100644 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -12,6 +12,12 @@ import ( "github.com/stretchr/testify/mock" ) +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + // ScrobbleList represents the ScrobbleList data structure. type ScrobbleList struct { Scrobbles []ScrobbleRef `json:"scrobbles"` @@ -41,18 +47,6 @@ type mockScrobbleRetrieverService struct { // Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} -// 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 -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - return ScrobbleRetrieverMock.GetLastTimestamp(username) -} - // GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { args := m.Called(username) @@ -65,6 +59,18 @@ 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 +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) @@ -75,3 +81,14 @@ func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options Scr 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. +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + return ScrobbleRetrieverMock.GetScrobbleCount(username, options) +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs index 35212aeab..c3062ab4d 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -6,6 +6,15 @@ use extism_pdk::*; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleCountOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScrobbleList { @@ -34,13 +43,13 @@ pub struct ScrobbleRef { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampRequest { +struct ScrobbleRetrieverGetFirstTimestampRequest { username: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampResponse { +struct ScrobbleRetrieverGetFirstTimestampResponse { #[serde(default)] result: Option, #[serde(default)] @@ -49,13 +58,13 @@ struct ScrobbleRetrieverGetLastTimestampResponse { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampRequest { +struct ScrobbleRetrieverGetLastTimestampRequest { username: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampResponse { +struct ScrobbleRetrieverGetLastTimestampResponse { #[serde(default)] result: Option, #[serde(default)] @@ -78,35 +87,28 @@ struct ScrobbleRetrieverGetScrobblesResponse { error: Option, } -#[host_fn] -extern "ExtismHost" { - fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; - fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; - fn scrobbleretriever_getscrobbles(input: Json) -> Json; +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountRequest { + username: String, + options: ScrobbleCountOptions, } -/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -/// -/// # Arguments -/// * `username` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_last_timestamp(username: &str) -> Result, Error> { - let response = unsafe { - scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { - username: username.to_owned(), - }))? - }; +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountResponse { + #[serde(default)] + result: i64, + #[serde(default)] + error: Option, +} - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) +#[host_fn] +extern "ExtismHost" { + fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; + fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; + fn scrobbleretriever_getscrobbles(input: Json) -> Json; + fn scrobbleretriever_getscrobblecount(input: Json) -> Json; } /// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user @@ -133,6 +135,30 @@ pub fn get_first_timestamp(username: &str) -> Result, Error> { Ok(response.0.result) } +/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_last_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + /// Calls the scrobbleretriever_getscrobbles host function. /// /// # Arguments @@ -158,3 +184,29 @@ pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result Result { + let response = unsafe { + scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod index 6265288f9..59486d796 100644 --- a/plugins/testdata/test-scrobble-retriever/go.mod +++ b/plugins/testdata/test-scrobble-retriever/go.mod @@ -1,4 +1,4 @@ -module test-sonic-similarity +module test-scrobble-retriever go 1.25 diff --git a/plugins/testdata/test-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go index 21102bf94..c9ab17eec 100644 --- a/plugins/testdata/test-scrobble-retriever/main.go +++ b/plugins/testdata/test-scrobble-retriever/main.go @@ -1,6 +1,8 @@ package main import ( + "strconv" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -40,3 +42,65 @@ func callGetLastTimestamp() int32 { 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 +}