mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(plugins): implement scrobbler plugin with authorization and scrobbling capabilities
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
df2b28ed4d
commit
2a030b789a
@ -74,6 +74,132 @@ Provides artist and album metadata. A plugin has this capability if it exports o
|
|||||||
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info |
|
||||||
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images |
|
||||||
|
|
||||||
|
### Scrobbler
|
||||||
|
|
||||||
|
Provides scrobbling (listening history) integration with external services. A plugin has this capability if it exports one or more of these functions:
|
||||||
|
|
||||||
|
| Function | Input | Output | Description |
|
||||||
|
|------------------------------|-----------------------|-------------------------|-------------------------------|
|
||||||
|
| `nd_scrobbler_is_authorized` | `{user_id, username}` | `{authorized}` | Check if user is authorized |
|
||||||
|
| `nd_scrobbler_now_playing` | See NowPlaying Input | `{error?, error_type?}` | Send now playing notification |
|
||||||
|
| `nd_scrobbler_scrobble` | See Scrobble Input | `{error?, error_type?}` | Submit a scrobble |
|
||||||
|
|
||||||
|
#### NowPlaying Input
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "string",
|
||||||
|
"username": "string",
|
||||||
|
"track": {
|
||||||
|
"id": "string",
|
||||||
|
"title": "string",
|
||||||
|
"album": "string",
|
||||||
|
"artist": "string",
|
||||||
|
"album_artist": "string",
|
||||||
|
"duration": 180.5,
|
||||||
|
"track_number": 1,
|
||||||
|
"disc_number": 1,
|
||||||
|
"mbz_recording_id": "string",
|
||||||
|
"mbz_album_id": "string",
|
||||||
|
"mbz_artist_id": "string",
|
||||||
|
"mbz_release_group_id": "string",
|
||||||
|
"mbz_album_artist_id": "string",
|
||||||
|
"mbz_release_track_id": "string"
|
||||||
|
},
|
||||||
|
"position": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scrobble Input
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "string",
|
||||||
|
"username": "string",
|
||||||
|
"track": { /* same as NowPlaying */ },
|
||||||
|
"timestamp": 1703270400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scrobbler Output
|
||||||
|
|
||||||
|
The output for `nd_scrobbler_now_playing` and `nd_scrobbler_scrobble` is **optional on success**. If there is no error, the plugin can return nothing (empty output).
|
||||||
|
|
||||||
|
On error, return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "error message",
|
||||||
|
"error_type": "not_authorized|retry_later|unrecoverable"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error types:**
|
||||||
|
- `not_authorized`: User needs to re-authorize with the scrobbling service
|
||||||
|
- `retry_later`: Temporary failure, Navidrome will retry the scrobble later
|
||||||
|
- `unrecoverable`: Permanent failure, scrobble will be discarded
|
||||||
|
|
||||||
|
#### Example Scrobbler Plugin
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/extism/go-pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthOutput struct {
|
||||||
|
Authorized bool `json:"authorized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrobblerOutput struct {
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_scrobbler_is_authorized
|
||||||
|
func ndScrobblerIsAuthorized() int32 {
|
||||||
|
var input AuthInput
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authorized with your scrobbling service
|
||||||
|
// This could check a session key stored in plugin config
|
||||||
|
sessionKey, hasKey := pdk.GetConfig("session_key_" + input.UserID)
|
||||||
|
|
||||||
|
output := AuthOutput{Authorized: hasKey && sessionKey != ""}
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_scrobbler_scrobble
|
||||||
|
func ndScrobblerScrobble() int32 {
|
||||||
|
// Read input, send to external service...
|
||||||
|
|
||||||
|
output := ScrobblerOutput{ErrorType: "none"}
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Scrobbler plugins are automatically discovered and used by Navidrome's PlayTracker alongside built-in scrobblers (Last.fm, ListenBrainz).
|
||||||
|
|
||||||
## Developing Plugins
|
## Developing Plugins
|
||||||
|
|
||||||
Plugins can be written in any language that compiles to WebAssembly. We recommend using the [Extism PDK](https://extism.org/docs/category/write-a-plug-in) for your language.
|
Plugins can be written in any language that compiles to WebAssembly. We recommend using the [Extism PDK](https://extism.org/docs/category/write-a-plug-in) for your language.
|
||||||
|
|||||||
@ -9,8 +9,9 @@ const (
|
|||||||
// Detected when the plugin exports at least one of the metadata agent functions.
|
// Detected when the plugin exports at least one of the metadata agent functions.
|
||||||
CapabilityMetadataAgent Capability = "MetadataAgent"
|
CapabilityMetadataAgent Capability = "MetadataAgent"
|
||||||
|
|
||||||
// Future capabilities:
|
// CapabilityScrobbler indicates the plugin can receive scrobble events.
|
||||||
// CapabilityScrobbler Capability = "Scrobbler"
|
// Detected when the plugin exports at least one of the scrobbler functions.
|
||||||
|
CapabilityScrobbler Capability = "Scrobbler"
|
||||||
)
|
)
|
||||||
|
|
||||||
// capabilityFunctions maps each capability to its required/optional functions.
|
// capabilityFunctions maps each capability to its required/optional functions.
|
||||||
@ -26,6 +27,11 @@ var capabilityFunctions = map[Capability][]string{
|
|||||||
FuncGetAlbumInfo,
|
FuncGetAlbumInfo,
|
||||||
FuncGetAlbumImages,
|
FuncGetAlbumImages,
|
||||||
},
|
},
|
||||||
|
CapabilityScrobbler: {
|
||||||
|
FuncScrobblerIsAuthorized,
|
||||||
|
FuncScrobblerNowPlaying,
|
||||||
|
FuncScrobblerScrobble,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// functionExistsChecker is an interface for checking if a function exists in a plugin.
|
// functionExistsChecker is an interface for checking if a function exists in a plugin.
|
||||||
|
|||||||
@ -202,8 +202,19 @@ func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) {
|
|||||||
// LoadScrobbler loads and returns a scrobbler plugin by name.
|
// LoadScrobbler loads and returns a scrobbler plugin by name.
|
||||||
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
|
// Returns false if the plugin is not found or doesn't have the Scrobbler capability.
|
||||||
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||||
// Scrobbler capability is not yet implemented
|
m.mu.RLock()
|
||||||
return nil, false
|
instance, ok := m.plugins[name]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok || !hasCapability(instance.capabilities, CapabilityScrobbler) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new scrobbler adapter for this plugin
|
||||||
|
return &ScrobblerPlugin{
|
||||||
|
name: instance.name,
|
||||||
|
plugin: instance,
|
||||||
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluginInfo contains basic information about a plugin for metrics/insights.
|
// PluginInfo contains basic information about a plugin for metrics/insights.
|
||||||
@ -479,9 +490,11 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *pluginInstanc
|
|||||||
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
return result, fmt.Errorf("plugin call exited with code %d", exit)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(output, &result)
|
if len(output) > 0 {
|
||||||
if err != nil {
|
err = json.Unmarshal(output, &result)
|
||||||
log.Trace(ctx, "Plugin call failed", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
|
if err != nil {
|
||||||
|
log.Trace(ctx, "Plugin call failed", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start), err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Trace(ctx, "Plugin call succeeded", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
log.Trace(ctx, "Plugin call succeeded", "p", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start))
|
||||||
|
|||||||
@ -46,20 +46,29 @@ func buildTestPlugins(t *testing.T, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// createTestManager creates a new plugin Manager with the given plugin config.
|
// createTestManager creates a new plugin Manager with the given plugin config.
|
||||||
// It creates a temp directory, copies the fake plugin, and starts the manager.
|
// It creates a temp directory, copies the fake-metadata-agent plugin, and starts the manager.
|
||||||
// Returns the manager, temp directory path, and a cleanup function.
|
// Returns the manager, temp directory path, and a cleanup function.
|
||||||
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
|
func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) {
|
||||||
|
return createTestManagerWithPlugins(pluginConfig, "fake-metadata-agent.wasm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config
|
||||||
|
// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager.
|
||||||
|
// Returns the manager and temp directory path.
|
||||||
|
func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) {
|
||||||
// Create temp directory
|
// Create temp directory
|
||||||
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
|
tmpDir, err := os.MkdirTemp("", "plugins-test-*")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// Copy test plugin to temp dir
|
// Copy test plugins to temp dir
|
||||||
srcPath := filepath.Join(testdataDir, "fake-metadata-agent.wasm")
|
for _, plugin := range plugins {
|
||||||
destPath := filepath.Join(tmpDir, "fake-metadata-agent.wasm")
|
srcPath := filepath.Join(testdataDir, plugin)
|
||||||
data, err := os.ReadFile(srcPath)
|
destPath := filepath.Join(tmpDir, plugin)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
data, err := os.ReadFile(srcPath)
|
||||||
err = os.WriteFile(destPath, data, 0600)
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(err).ToNot(HaveOccurred())
|
err = os.WriteFile(destPath, data, 0600)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
}
|
||||||
|
|
||||||
// Setup config
|
// Setup config
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
|||||||
135
plugins/scrobbler_adapter.go
Normal file
135
plugins/scrobbler_adapter.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scrobbler function names (snake_case as per design)
|
||||||
|
const (
|
||||||
|
FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized"
|
||||||
|
FuncScrobblerNowPlaying = "nd_scrobbler_now_playing"
|
||||||
|
FuncScrobblerScrobble = "nd_scrobbler_scrobble"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScrobblerPlugin is an adapter that wraps an Extism plugin and implements
|
||||||
|
// the scrobbler.Scrobbler interface for scrobbling to external services.
|
||||||
|
type ScrobblerPlugin struct {
|
||||||
|
name string
|
||||||
|
plugin *pluginInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthorized checks if the user is authorized with this scrobbler
|
||||||
|
func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||||
|
username := getUsernameFromContext(ctx)
|
||||||
|
input := scrobblerAuthInput{
|
||||||
|
UserID: userId,
|
||||||
|
Username: username,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := callPluginFunction[scrobblerAuthInput, scrobblerAuthOutput](ctx, s.plugin, FuncScrobblerIsAuthorized, input)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowPlaying sends a now playing notification to the scrobbler
|
||||||
|
func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||||
|
username := getUsernameFromContext(ctx)
|
||||||
|
input := scrobblerNowPlayingInput{
|
||||||
|
UserID: userId,
|
||||||
|
Username: username,
|
||||||
|
Track: mediaFileToTrackInfo(track),
|
||||||
|
Position: position,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := callPluginFunction[scrobblerNowPlayingInput, scrobblerOutput](ctx, s.plugin, FuncScrobblerNowPlaying, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapScrobblerError(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrobble submits a scrobble to the scrobbler
|
||||||
|
func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobbler.Scrobble) error {
|
||||||
|
username := getUsernameFromContext(ctx)
|
||||||
|
input := scrobblerScrobbleInput{
|
||||||
|
UserID: userId,
|
||||||
|
Username: username,
|
||||||
|
Track: mediaFileToTrackInfo(&sc.MediaFile),
|
||||||
|
Timestamp: sc.TimeStamp.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := callPluginFunction[scrobblerScrobbleInput, scrobblerOutput](ctx, s.plugin, FuncScrobblerScrobble, input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapScrobblerError(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUsernameFromContext extracts the username from the request context
|
||||||
|
func getUsernameFromContext(ctx context.Context) string {
|
||||||
|
if user, ok := request.UserFrom(ctx); ok {
|
||||||
|
return user.UserName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// mediaFileToTrackInfo converts a model.MediaFile to scrobblerTrackInfo
|
||||||
|
func mediaFileToTrackInfo(mf *model.MediaFile) scrobblerTrackInfo {
|
||||||
|
return scrobblerTrackInfo{
|
||||||
|
ID: mf.ID,
|
||||||
|
Title: mf.Title,
|
||||||
|
Album: mf.Album,
|
||||||
|
Artist: mf.Artist,
|
||||||
|
AlbumArtist: mf.AlbumArtist,
|
||||||
|
Duration: mf.Duration,
|
||||||
|
TrackNumber: mf.TrackNumber,
|
||||||
|
DiscNumber: mf.DiscNumber,
|
||||||
|
MbzRecordingID: mf.MbzRecordingID,
|
||||||
|
MbzAlbumID: mf.MbzAlbumID,
|
||||||
|
MbzArtistID: mf.MbzArtistID,
|
||||||
|
MbzReleaseGroupID: mf.MbzReleaseGroupID,
|
||||||
|
MbzAlbumArtistID: mf.MbzAlbumArtistID,
|
||||||
|
MbzReleaseTrackID: mf.MbzReleaseTrackID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapScrobblerError converts the plugin output error to a scrobbler error
|
||||||
|
func mapScrobblerError(output scrobblerOutput) error {
|
||||||
|
switch output.ErrorType {
|
||||||
|
case scrobblerErrorNone, "":
|
||||||
|
return nil
|
||||||
|
case scrobblerErrorNotAuthorized:
|
||||||
|
if output.Error != "" {
|
||||||
|
return fmt.Errorf("%w: %s", scrobbler.ErrNotAuthorized, output.Error)
|
||||||
|
}
|
||||||
|
return scrobbler.ErrNotAuthorized
|
||||||
|
case scrobblerErrorRetryLater:
|
||||||
|
if output.Error != "" {
|
||||||
|
return fmt.Errorf("%w: %s", scrobbler.ErrRetryLater, output.Error)
|
||||||
|
}
|
||||||
|
return scrobbler.ErrRetryLater
|
||||||
|
case scrobblerErrorUnrecoverable:
|
||||||
|
if output.Error != "" {
|
||||||
|
return fmt.Errorf("%w: %s", scrobbler.ErrUnrecoverable, output.Error)
|
||||||
|
}
|
||||||
|
return scrobbler.ErrUnrecoverable
|
||||||
|
default:
|
||||||
|
if output.Error != "" {
|
||||||
|
return fmt.Errorf("unknown error type %q: %s", output.ErrorType, output.Error)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("unknown error type: %s", output.ErrorType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify interface implementation at compile time
|
||||||
|
var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil)
|
||||||
213
plugins/scrobbler_adapter_test.go
Normal file
213
plugins/scrobbler_adapter_test.go
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("ScrobblerPlugin", Ordered, func() {
|
||||||
|
var (
|
||||||
|
scrobblerManager *Manager
|
||||||
|
s scrobbler.Scrobbler
|
||||||
|
ctx context.Context
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeAll(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
|
// Add user to context for username extraction
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "user-1", UserName: "testuser"})
|
||||||
|
|
||||||
|
// Load the scrobbler via a new manager with the fake-scrobbler plugin
|
||||||
|
scrobblerManager, _ = createTestManagerWithPlugins(nil, "fake-scrobbler.wasm")
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
s, ok = scrobblerManager.LoadScrobbler("fake-scrobbler")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("LoadScrobbler", func() {
|
||||||
|
It("returns a scrobbler for a plugin with Scrobbler capability", func() {
|
||||||
|
Expect(s).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for a plugin without Scrobbler capability", func() {
|
||||||
|
_, ok := testManager.LoadScrobbler("fake-metadata-agent")
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false for non-existent plugin", func() {
|
||||||
|
_, ok := scrobblerManager.LoadScrobbler("non-existent")
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("IsAuthorized", func() {
|
||||||
|
It("returns true when plugin is configured to authorize", func() {
|
||||||
|
result := s.IsAuthorized(ctx, "user-1")
|
||||||
|
Expect(result).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns false when plugin is configured to not authorize", func() {
|
||||||
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||||
|
"fake-scrobbler": {"authorized": "false"},
|
||||||
|
}, "fake-scrobbler.wasm")
|
||||||
|
|
||||||
|
sc, ok := manager.LoadScrobbler("fake-scrobbler")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
result := sc.IsAuthorized(ctx, "user-1")
|
||||||
|
Expect(result).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("NowPlaying", func() {
|
||||||
|
It("successfully calls the plugin", func() {
|
||||||
|
track := &model.MediaFile{
|
||||||
|
ID: "track-1",
|
||||||
|
Title: "Test Song",
|
||||||
|
Album: "Test Album",
|
||||||
|
Artist: "Test Artist",
|
||||||
|
AlbumArtist: "Test Album Artist",
|
||||||
|
Duration: 180,
|
||||||
|
TrackNumber: 1,
|
||||||
|
DiscNumber: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.NowPlaying(ctx, "user-1", track, 30)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when plugin returns error", func() {
|
||||||
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||||
|
"fake-scrobbler": {"error": "service unavailable", "error_type": "retry_later"},
|
||||||
|
}, "fake-scrobbler.wasm")
|
||||||
|
|
||||||
|
sc, ok := manager.LoadScrobbler("fake-scrobbler")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
track := &model.MediaFile{ID: "track-1", Title: "Test Song"}
|
||||||
|
err := sc.NowPlaying(ctx, "user-1", track, 30)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err).To(MatchError(ContainSubstring("retry later")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Scrobble", func() {
|
||||||
|
It("successfully calls the plugin", func() {
|
||||||
|
sc := scrobbler.Scrobble{
|
||||||
|
MediaFile: model.MediaFile{
|
||||||
|
ID: "track-1",
|
||||||
|
Title: "Test Song",
|
||||||
|
Album: "Test Album",
|
||||||
|
Artist: "Test Artist",
|
||||||
|
AlbumArtist: "Test Album Artist",
|
||||||
|
Duration: 180,
|
||||||
|
TrackNumber: 1,
|
||||||
|
DiscNumber: 1,
|
||||||
|
},
|
||||||
|
TimeStamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.Scrobble(ctx, "user-1", sc)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when plugin returns not_authorized error", func() {
|
||||||
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||||
|
"fake-scrobbler": {"error": "user not linked", "error_type": "not_authorized"},
|
||||||
|
}, "fake-scrobbler.wasm")
|
||||||
|
|
||||||
|
sc, ok := manager.LoadScrobbler("fake-scrobbler")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
scrobble := scrobbler.Scrobble{
|
||||||
|
MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
|
||||||
|
TimeStamp: time.Now(),
|
||||||
|
}
|
||||||
|
err := sc.Scrobble(ctx, "user-1", scrobble)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err).To(MatchError(ContainSubstring("not authorized")))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when plugin returns unrecoverable error", func() {
|
||||||
|
manager, _ := createTestManagerWithPlugins(map[string]map[string]string{
|
||||||
|
"fake-scrobbler": {"error": "track rejected", "error_type": "unrecoverable"},
|
||||||
|
}, "fake-scrobbler.wasm")
|
||||||
|
|
||||||
|
sc, ok := manager.LoadScrobbler("fake-scrobbler")
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
scrobble := scrobbler.Scrobble{
|
||||||
|
MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"},
|
||||||
|
TimeStamp: time.Now(),
|
||||||
|
}
|
||||||
|
err := sc.Scrobble(ctx, "user-1", scrobble)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err).To(MatchError(ContainSubstring("unrecoverable")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("PluginNames", func() {
|
||||||
|
It("returns plugin names with Scrobbler capability", func() {
|
||||||
|
names := scrobblerManager.PluginNames("Scrobbler")
|
||||||
|
Expect(names).To(ContainElement("fake-scrobbler"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not return metadata agent plugins for Scrobbler capability", func() {
|
||||||
|
names := testManager.PluginNames("Scrobbler")
|
||||||
|
Expect(names).ToNot(ContainElement("fake-metadata-agent"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("mapScrobblerError", func() {
|
||||||
|
It("returns nil for empty error type", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: ""}
|
||||||
|
Expect(mapScrobblerError(output)).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns nil for 'none' error type", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: "none"}
|
||||||
|
Expect(mapScrobblerError(output)).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns ErrNotAuthorized for 'not_authorized' error type", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: "not_authorized"}
|
||||||
|
err := mapScrobblerError(output)
|
||||||
|
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns ErrNotAuthorized with message", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: "not_authorized", Error: "user not linked"}
|
||||||
|
err := mapScrobblerError(output)
|
||||||
|
Expect(err).To(MatchError(ContainSubstring("not authorized")))
|
||||||
|
Expect(err).To(MatchError(ContainSubstring("user not linked")))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns ErrRetryLater for 'retry_later' error type", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: "retry_later"}
|
||||||
|
err := mapScrobblerError(output)
|
||||||
|
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns ErrUnrecoverable for 'unrecoverable' error type", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: "unrecoverable"}
|
||||||
|
err := mapScrobblerError(output)
|
||||||
|
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for unknown error type", func() {
|
||||||
|
output := scrobblerOutput{ErrorType: "unknown"}
|
||||||
|
err := mapScrobblerError(output)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
Expect(err.Error()).To(ContainSubstring("unknown error type"))
|
||||||
|
})
|
||||||
|
})
|
||||||
62
plugins/scrobbler_types.go
Normal file
62
plugins/scrobbler_types.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
// --- Input/Output JSON structures for Scrobbler plugin calls ---
|
||||||
|
|
||||||
|
// scrobblerAuthInput is the input for IsAuthorized
|
||||||
|
type scrobblerAuthInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrobblerAuthOutput is the output for IsAuthorized
|
||||||
|
type scrobblerAuthOutput struct {
|
||||||
|
Authorized bool `json:"authorized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrobblerTrackInfo contains track metadata for scrobbling
|
||||||
|
type scrobblerTrackInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
Duration float32 `json:"duration"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
MbzRecordingID string `json:"mbz_recording_id,omitempty"`
|
||||||
|
MbzAlbumID string `json:"mbz_album_id,omitempty"`
|
||||||
|
MbzArtistID string `json:"mbz_artist_id,omitempty"`
|
||||||
|
MbzReleaseGroupID string `json:"mbz_release_group_id,omitempty"`
|
||||||
|
MbzAlbumArtistID string `json:"mbz_album_artist_id,omitempty"`
|
||||||
|
MbzReleaseTrackID string `json:"mbz_release_track_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrobblerNowPlayingInput is the input for NowPlaying
|
||||||
|
type scrobblerNowPlayingInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Track scrobblerTrackInfo `json:"track"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrobblerScrobbleInput is the input for Scrobble
|
||||||
|
type scrobblerScrobbleInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Track scrobblerTrackInfo `json:"track"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrobblerOutput is the output for NowPlaying and Scrobble
|
||||||
|
type scrobblerOutput struct {
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorType string `json:"error_type,omitempty"` // "none", "not_authorized", "retry_later", "unrecoverable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrobbler error type constants
|
||||||
|
const (
|
||||||
|
scrobblerErrorNone = "none"
|
||||||
|
scrobblerErrorNotAuthorized = "not_authorized"
|
||||||
|
scrobblerErrorRetryLater = "retry_later"
|
||||||
|
scrobblerErrorUnrecoverable = "unrecoverable"
|
||||||
|
)
|
||||||
5
plugins/testdata/fake-scrobbler/go.mod
vendored
Normal file
5
plugins/testdata/fake-scrobbler/go.mod
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module fake-scrobbler
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/extism/go-pdk v1.1.3
|
||||||
2
plugins/testdata/fake-scrobbler/go.sum
vendored
Normal file
2
plugins/testdata/fake-scrobbler/go.sum
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
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=
|
||||||
190
plugins/testdata/fake-scrobbler/main.go
vendored
Normal file
190
plugins/testdata/fake-scrobbler/main.go
vendored
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
// Fake scrobbler plugin for Navidrome plugin system integration tests.
|
||||||
|
// Build with: tinygo build -o ../fake-scrobbler.wasm -target wasip1 -buildmode=c-shared ./main.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/extism/go-pdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest types
|
||||||
|
type Manifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrobbler input/output types
|
||||||
|
|
||||||
|
type AuthInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthOutput struct {
|
||||||
|
Authorized bool `json:"authorized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
AlbumArtist string `json:"album_artist"`
|
||||||
|
Duration float32 `json:"duration"`
|
||||||
|
TrackNumber int `json:"track_number"`
|
||||||
|
DiscNumber int `json:"disc_number"`
|
||||||
|
MbzRecordingID string `json:"mbz_recording_id,omitempty"`
|
||||||
|
MbzAlbumID string `json:"mbz_album_id,omitempty"`
|
||||||
|
MbzArtistID string `json:"mbz_artist_id,omitempty"`
|
||||||
|
MbzReleaseGroupID string `json:"mbz_release_group_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NowPlayingInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Track TrackInfo `json:"track"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrobbleInput struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Track TrackInfo `json:"track"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrobblerOutput struct {
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkConfigError checks if the plugin is configured to return an error.
|
||||||
|
// If "error" config is set, it returns the error message and error type.
|
||||||
|
func checkConfigError() (bool, string, string) {
|
||||||
|
errMsg, hasErr := pdk.GetConfig("error")
|
||||||
|
if !hasErr || errMsg == "" {
|
||||||
|
return false, "", ""
|
||||||
|
}
|
||||||
|
errType, _ := pdk.GetConfig("error_type")
|
||||||
|
if errType == "" {
|
||||||
|
errType = "unrecoverable"
|
||||||
|
}
|
||||||
|
return true, errMsg, errType
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAuthConfig returns whether the plugin is configured to authorize users.
|
||||||
|
// If "authorized" config is set to "false", users are not authorized.
|
||||||
|
// Default is true (authorized).
|
||||||
|
func checkAuthConfig() bool {
|
||||||
|
authStr, hasAuth := pdk.GetConfig("authorized")
|
||||||
|
if !hasAuth {
|
||||||
|
return true // Default: authorized
|
||||||
|
}
|
||||||
|
auth, err := strconv.ParseBool(authStr)
|
||||||
|
if err != nil {
|
||||||
|
return true // Default on parse error
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_manifest
|
||||||
|
func ndManifest() int32 {
|
||||||
|
manifest := Manifest{
|
||||||
|
Name: "Fake Scrobbler",
|
||||||
|
Author: "Navidrome Test",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Description: "A fake scrobbler plugin for integration testing",
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
pdk.Output(out)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_scrobbler_is_authorized
|
||||||
|
func ndScrobblerIsAuthorized() int32 {
|
||||||
|
var input AuthInput
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
output := AuthOutput{
|
||||||
|
Authorized: checkAuthConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_scrobbler_now_playing
|
||||||
|
func ndScrobblerNowPlaying() int32 {
|
||||||
|
var input NowPlayingInput
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for configured error
|
||||||
|
hasErr, errMsg, errType := checkConfigError()
|
||||||
|
if hasErr {
|
||||||
|
output := ScrobblerOutput{
|
||||||
|
Error: errMsg,
|
||||||
|
ErrorType: errType,
|
||||||
|
}
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the now playing (for potential debugging)
|
||||||
|
// In a real plugin, this would send to an external service
|
||||||
|
pdk.Log(pdk.LogInfo, "NowPlaying: "+input.Track.Title+" by "+input.Track.Artist)
|
||||||
|
|
||||||
|
// Success - no output needed
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:wasmexport nd_scrobbler_scrobble
|
||||||
|
func ndScrobblerScrobble() int32 {
|
||||||
|
var input ScrobbleInput
|
||||||
|
if err := pdk.InputJSON(&input); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for configured error
|
||||||
|
hasErr, errMsg, errType := checkConfigError()
|
||||||
|
if hasErr {
|
||||||
|
output := ScrobblerOutput{
|
||||||
|
Error: errMsg,
|
||||||
|
ErrorType: errType,
|
||||||
|
}
|
||||||
|
if err := pdk.OutputJSON(output); err != nil {
|
||||||
|
pdk.SetError(err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the scrobble (for potential debugging)
|
||||||
|
// In a real plugin, this would send to an external service
|
||||||
|
pdk.Log(pdk.LogInfo, "Scrobble: "+input.Track.Title+" by "+input.Track.Artist)
|
||||||
|
|
||||||
|
// Success - no output needed
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
Loading…
x
Reference in New Issue
Block a user