update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
1760
plugins/README.md
Normal file
1760
plugins/README.md
Normal file
File diff suppressed because it is too large
Load Diff
166
plugins/adapter_media_agent.go
Normal file
166
plugins/adapter_media_agent.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
|
||||
func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmMediaAgent{
|
||||
baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityMetadataAgent,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
|
||||
type wasmMediaAgent struct {
|
||||
*baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) AgentName() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) mapError(err error) error {
|
||||
if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
|
||||
return agents.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Album-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
|
||||
return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
if res == nil || res.Info == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
info := res.Info
|
||||
return &agents.AlbumInfo{
|
||||
Name: info.Name,
|
||||
MBID: info.Mbid,
|
||||
Description: info.Description,
|
||||
URL: info.Url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
|
||||
return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
}
|
||||
|
||||
// Artist-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
|
||||
return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetMbid(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
|
||||
return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetUrl(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
|
||||
return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetBiography(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
|
||||
return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
||||
for _, a := range resp.GetArtists() {
|
||||
artists = append(artists, agents.Artist{
|
||||
Name: a.GetName(),
|
||||
MBID: a.GetMbid(),
|
||||
})
|
||||
}
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
|
||||
return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(resp.Images), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
|
||||
return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
||||
for _, s := range resp.GetSongs() {
|
||||
songs = append(songs, agents.Song{
|
||||
Name: s.GetName(),
|
||||
MBID: s.GetMbid(),
|
||||
})
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// Helper function to convert ExternalImage objects from the API to the agents package
|
||||
func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
|
||||
result := make([]agents.ExternalImage, 0, len(images))
|
||||
for _, img := range images {
|
||||
result = append(result, agents.ExternalImage{
|
||||
URL: img.GetUrl(),
|
||||
Size: int(img.GetSize()),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
229
plugins/adapter_media_agent_test.go
Normal file
229
plugins/adapter_media_agent_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Adapter Media Agent", func() {
|
||||
var ctx context.Context
|
||||
var mgr *managerImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Ensure plugins folder is set to testdata
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
||||
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
|
||||
// Wait for all plugins to compile to avoid race conditions
|
||||
err := mgr.EnsureCompiled("multi_plugin")
|
||||
Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
|
||||
err = mgr.EnsureCompiled("fake_album_agent")
|
||||
Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
|
||||
})
|
||||
|
||||
Describe("AgentName and PluginName", func() {
|
||||
It("should return the plugin name", func() {
|
||||
agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
|
||||
Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
|
||||
Expect(agent.PluginID()).To(Equal("multi_plugin"))
|
||||
})
|
||||
It("should return the agent name", func() {
|
||||
agent, ok := mgr.LoadMediaAgent("multi_plugin")
|
||||
Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
|
||||
Expect(agent.AgentName()).To(Equal("multi_plugin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_album_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetAlbumInfo", func() {
|
||||
It("should return album information", func() {
|
||||
info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(info).NotTo(BeNil())
|
||||
Expect(info.Name).To(Equal("Test Album"))
|
||||
Expect(info.MBID).To(Equal("album-mbid-123"))
|
||||
Expect(info.Description).To(Equal("This is a test album description"))
|
||||
Expect(info.URL).To(Equal("https://example.com/album"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns nil response", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "", "", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetAlbumImages", func() {
|
||||
It("should return album images", func() {
|
||||
images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/album1.jpg", Size: 300},
|
||||
{URL: "https://example.com/album2.jpg", Size: 400},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Artist methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_artist_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetArtistMBID", func() {
|
||||
It("should return artist MBID", func() {
|
||||
mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mbid).To(Equal("1234567890"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetArtistMBID(ctx, "artist-id", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistURL", func() {
|
||||
It("should return artist URL", func() {
|
||||
url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(url).To(Equal("https://example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistBiography", func() {
|
||||
It("should return artist biography", func() {
|
||||
bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(bio).To(Equal("This is a test biography"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetSimilarArtists", func() {
|
||||
It("should return similar artists", func() {
|
||||
artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(artists).To(Equal([]agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistImages", func() {
|
||||
It("should return artist images", func() {
|
||||
images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/image1.jpg", Size: 100},
|
||||
{URL: "https://example.com/image2.jpg", Size: 200},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistTopSongs", func() {
|
||||
It("should return artist top songs", func() {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(songs).To(Equal([]agents.Song{
|
||||
{Name: "Song 1", MBID: "mbid1"},
|
||||
{Name: "Song 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Helper functions", func() {
|
||||
It("convertExternalImages should convert API image objects to agent image objects", func() {
|
||||
apiImages := []*api.ExternalImage{
|
||||
{Url: "https://example.com/image1.jpg", Size: 100},
|
||||
{Url: "https://example.com/image2.jpg", Size: 200},
|
||||
}
|
||||
|
||||
agentImages := convertExternalImages(apiImages)
|
||||
Expect(agentImages).To(HaveLen(2))
|
||||
|
||||
for i, img := range agentImages {
|
||||
Expect(img.URL).To(Equal(apiImages[i].Url))
|
||||
Expect(img.Size).To(Equal(int(apiImages[i].Size)))
|
||||
}
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle empty slice", func() {
|
||||
agentImages := convertExternalImages([]*api.ExternalImage{})
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle nil", func() {
|
||||
agentImages := convertExternalImages(nil)
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error mapping", func() {
|
||||
var agent wasmMediaAgent
|
||||
|
||||
It("should map API ErrNotFound to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotFound)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotImplemented)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should pass through other errors", func() {
|
||||
testErr := errors.New("test error")
|
||||
err := agent.mapError(testErr)
|
||||
Expect(err).To(Equal(testErr))
|
||||
})
|
||||
|
||||
It("should handle nil error", func() {
|
||||
err := agent.mapError(nil)
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
46
plugins/adapter_scheduler_callback.go
Normal file
46
plugins/adapter_scheduler_callback.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
|
||||
func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmSchedulerCallback{
|
||||
baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilitySchedulerCallback,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmSchedulerCallback adapts a SchedulerCallback plugin
|
||||
type wasmSchedulerCallback struct {
|
||||
*baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error {
|
||||
_, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) {
|
||||
return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{
|
||||
ScheduleId: scheduleID,
|
||||
Payload: payload,
|
||||
IsRecurring: isRecurring,
|
||||
})
|
||||
})
|
||||
return err
|
||||
}
|
||||
136
plugins/adapter_scrobbler.go
Normal file
136
plugins/adapter_scrobbler.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmScrobblerPlugin{
|
||||
baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityScrobbler,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type wasmScrobblerPlugin struct {
|
||||
*baseCapability[api.Scrobbler, *api.ScrobblerPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) {
|
||||
return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err)
|
||||
}
|
||||
return err == nil && resp.Authorized
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
trackInfo := w.toTrackInfo(track, position)
|
||||
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
trackInfo := w.toTrackInfo(&s.MediaFile, 0)
|
||||
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: s.TimeStamp.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo {
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
Position: int32(position),
|
||||
}
|
||||
return trackInfo
|
||||
}
|
||||
35
plugins/adapter_websocket_callback.go
Normal file
35
plugins/adapter_websocket_callback.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
|
||||
func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmWebSocketCallback{
|
||||
baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityWebSocketCallback,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmWebSocketCallback adapts a WebSocketCallback plugin
|
||||
type wasmWebSocketCallback struct {
|
||||
*baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
|
||||
}
|
||||
1136
plugins/api/api.pb.go
Normal file
1136
plugins/api/api.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
246
plugins/api/api.proto
Normal file
246
plugins/api/api.proto
Normal file
@@ -0,0 +1,246 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package api;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/api;api";
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service MetadataAgent {
|
||||
// Artist metadata methods
|
||||
rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse);
|
||||
rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse);
|
||||
rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse);
|
||||
rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse);
|
||||
rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse);
|
||||
rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse);
|
||||
|
||||
// Album metadata methods
|
||||
rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse);
|
||||
rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse);
|
||||
}
|
||||
|
||||
message ArtistMBIDRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message ArtistMBIDResponse {
|
||||
string mbid = 1;
|
||||
}
|
||||
|
||||
message ArtistURLRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ArtistURLResponse {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message ArtistBiographyRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ArtistBiographyResponse {
|
||||
string biography = 1;
|
||||
}
|
||||
|
||||
message ArtistSimilarRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
int32 limit = 4;
|
||||
}
|
||||
|
||||
message Artist {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
}
|
||||
|
||||
message ArtistSimilarResponse {
|
||||
repeated Artist artists = 1;
|
||||
}
|
||||
|
||||
message ArtistImageRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ExternalImage {
|
||||
string url = 1;
|
||||
int32 size = 2;
|
||||
}
|
||||
|
||||
message ArtistImageResponse {
|
||||
repeated ExternalImage images = 1;
|
||||
}
|
||||
|
||||
message ArtistTopSongsRequest {
|
||||
string id = 1;
|
||||
string artistName = 2;
|
||||
string mbid = 3;
|
||||
int32 count = 4;
|
||||
}
|
||||
|
||||
message Song {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
}
|
||||
|
||||
message ArtistTopSongsResponse {
|
||||
repeated Song songs = 1;
|
||||
}
|
||||
|
||||
message AlbumInfoRequest {
|
||||
string name = 1;
|
||||
string artist = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message AlbumInfo {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
string description = 3;
|
||||
string url = 4;
|
||||
}
|
||||
|
||||
message AlbumInfoResponse {
|
||||
AlbumInfo info = 1;
|
||||
}
|
||||
|
||||
message AlbumImagesRequest {
|
||||
string name = 1;
|
||||
string artist = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message AlbumImagesResponse {
|
||||
repeated ExternalImage images = 1;
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service Scrobbler {
|
||||
rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse);
|
||||
rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse);
|
||||
rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse);
|
||||
}
|
||||
|
||||
message ScrobblerIsAuthorizedRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
}
|
||||
|
||||
message ScrobblerIsAuthorizedResponse {
|
||||
bool authorized = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message TrackInfo {
|
||||
string id = 1;
|
||||
string mbid = 2;
|
||||
string name = 3;
|
||||
string album = 4;
|
||||
string album_mbid = 5;
|
||||
repeated Artist artists = 6;
|
||||
repeated Artist album_artists = 7;
|
||||
int32 length = 8; // seconds
|
||||
int32 position = 9; // seconds
|
||||
}
|
||||
|
||||
message ScrobblerNowPlayingRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
TrackInfo track = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message ScrobblerNowPlayingResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message ScrobblerScrobbleRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
TrackInfo track = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message ScrobblerScrobbleResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service SchedulerCallback {
|
||||
rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
|
||||
}
|
||||
|
||||
message SchedulerCallbackRequest {
|
||||
string schedule_id = 1; // ID of the scheduled job that triggered this callback
|
||||
bytes payload = 2; // The data passed when the job was scheduled
|
||||
bool is_recurring = 3; // Whether this is from a recurring schedule (cron job)
|
||||
}
|
||||
|
||||
message SchedulerCallbackResponse {
|
||||
string error = 1; // Error message if the callback failed
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service LifecycleManagement {
|
||||
rpc OnInit(InitRequest) returns (InitResponse);
|
||||
}
|
||||
|
||||
message InitRequest {
|
||||
map<string, string> config = 1; // Configuration specific to this plugin
|
||||
}
|
||||
|
||||
message InitResponse {
|
||||
string error = 1; // Error message if initialization failed
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service WebSocketCallback {
|
||||
// Called when a text message is received
|
||||
rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
|
||||
|
||||
// Called when a binary message is received
|
||||
rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
|
||||
|
||||
// Called when an error occurs
|
||||
rpc OnError(OnErrorRequest) returns (OnErrorResponse);
|
||||
|
||||
// Called when the connection is closed
|
||||
rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
|
||||
}
|
||||
|
||||
message OnTextMessageRequest {
|
||||
string connection_id = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message OnTextMessageResponse {}
|
||||
|
||||
message OnBinaryMessageRequest {
|
||||
string connection_id = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message OnBinaryMessageResponse {}
|
||||
|
||||
message OnErrorRequest {
|
||||
string connection_id = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message OnErrorResponse {}
|
||||
|
||||
message OnCloseRequest {
|
||||
string connection_id = 1;
|
||||
int32 code = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message OnCloseResponse {}
|
||||
1688
plugins/api/api_host.pb.go
Normal file
1688
plugins/api/api_host.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
47
plugins/api/api_options.pb.go
Normal file
47
plugins/api/api_options.pb.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
type wazeroConfigOption func(plugin *WazeroConfig)
|
||||
|
||||
type WazeroNewRuntime func(context.Context) (wazero.Runtime, error)
|
||||
|
||||
type WazeroConfig struct {
|
||||
newRuntime func(context.Context) (wazero.Runtime, error)
|
||||
moduleConfig wazero.ModuleConfig
|
||||
}
|
||||
|
||||
func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.newRuntime = newRuntime
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultWazeroRuntime() WazeroNewRuntime {
|
||||
return func(ctx context.Context) (wazero.Runtime, error) {
|
||||
r := wazero.NewRuntime(ctx)
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.moduleConfig = moduleConfig
|
||||
}
|
||||
}
|
||||
487
plugins/api/api_plugin.pb.go
Normal file
487
plugins/api/api_plugin.pb.go
Normal file
@@ -0,0 +1,487 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
)
|
||||
|
||||
const MetadataAgentPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport metadata_agent_api_version
|
||||
func _metadata_agent_api_version() uint64 {
|
||||
return MetadataAgentPluginAPIVersion
|
||||
}
|
||||
|
||||
var metadataAgent MetadataAgent
|
||||
|
||||
func RegisterMetadataAgent(p MetadataAgent) {
|
||||
metadataAgent = p
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_mbid
|
||||
func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistMBIDRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistMBID(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_url
|
||||
func _metadata_agent_get_artist_url(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistURLRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistURL(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_biography
|
||||
func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistBiographyRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistBiography(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_similar_artists
|
||||
func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistSimilarRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetSimilarArtists(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_images
|
||||
func _metadata_agent_get_artist_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistImageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_top_songs
|
||||
func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistTopSongsRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistTopSongs(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_info
|
||||
func _metadata_agent_get_album_info(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumInfoRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumInfo(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_images
|
||||
func _metadata_agent_get_album_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumImagesRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const ScrobblerPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scrobbler_api_version
|
||||
func _scrobbler_api_version() uint64 {
|
||||
return ScrobblerPluginAPIVersion
|
||||
}
|
||||
|
||||
var scrobbler Scrobbler
|
||||
|
||||
func RegisterScrobbler(p Scrobbler) {
|
||||
scrobbler = p
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_is_authorized
|
||||
func _scrobbler_is_authorized(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerIsAuthorizedRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.IsAuthorized(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_now_playing
|
||||
func _scrobbler_now_playing(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerNowPlayingRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.NowPlaying(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_scrobble
|
||||
func _scrobbler_scrobble(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerScrobbleRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.Scrobble(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const SchedulerCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scheduler_callback_api_version
|
||||
func _scheduler_callback_api_version() uint64 {
|
||||
return SchedulerCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var schedulerCallback SchedulerCallback
|
||||
|
||||
func RegisterSchedulerCallback(p SchedulerCallback) {
|
||||
schedulerCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport scheduler_callback_on_scheduler_callback
|
||||
func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(SchedulerCallbackRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const LifecycleManagementPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport lifecycle_management_api_version
|
||||
func _lifecycle_management_api_version() uint64 {
|
||||
return LifecycleManagementPluginAPIVersion
|
||||
}
|
||||
|
||||
var lifecycleManagement LifecycleManagement
|
||||
|
||||
func RegisterLifecycleManagement(p LifecycleManagement) {
|
||||
lifecycleManagement = p
|
||||
}
|
||||
|
||||
//go:wasmexport lifecycle_management_on_init
|
||||
func _lifecycle_management_on_init(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(InitRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := lifecycleManagement.OnInit(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const WebSocketCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport web_socket_callback_api_version
|
||||
func _web_socket_callback_api_version() uint64 {
|
||||
return WebSocketCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var webSocketCallback WebSocketCallback
|
||||
|
||||
func RegisterWebSocketCallback(p WebSocketCallback) {
|
||||
webSocketCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_text_message
|
||||
func _web_socket_callback_on_text_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnTextMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnTextMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_binary_message
|
||||
func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnBinaryMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnBinaryMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_error
|
||||
func _web_socket_callback_on_error(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnErrorRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnError(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_close
|
||||
func _web_socket_callback_on_close(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnCloseRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnClose(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
34
plugins/api/api_plugin_dev.go
Normal file
34
plugins/api/api_plugin_dev.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
|
||||
// This file exists to provide stubs for the plugin registration functions when building for non-WASM targets.
|
||||
// This is useful for testing and development purposes, as it allows you to build and run your plugin code
|
||||
// without having to compile it to WASM.
|
||||
// In a real-world scenario, you would compile your plugin to WASM and use the generated registration functions.
|
||||
|
||||
func RegisterMetadataAgent(MetadataAgent) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterScrobbler(Scrobbler) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterSchedulerCallback(SchedulerCallback) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterLifecycleManagement(LifecycleManagement) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterWebSocketCallback(WebSocketCallback) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
|
||||
panic("not implemented")
|
||||
}
|
||||
94
plugins/api/api_plugin_dev_named_registry.go
Normal file
94
plugins/api/api_plugin_dev_named_registry.go
Normal file
@@ -0,0 +1,94 @@
|
||||
//go:build wasip1
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
)
|
||||
|
||||
var callbacks = make(namedCallbacks)
|
||||
|
||||
// RegisterNamedSchedulerCallback registers a named scheduler callback. Named callbacks allow multiple callbacks to be registered
|
||||
// within the same plugin, and for the schedules to be scoped to the named callback. If you only need a single callback, you can use
|
||||
// the default (unnamed) callback registration function, RegisterSchedulerCallback.
|
||||
// It returns a scheduler.SchedulerService that can be used to schedule jobs for the named callback.
|
||||
//
|
||||
// Notes:
|
||||
//
|
||||
// - You can't mix named and unnamed callbacks within the same plugin.
|
||||
// - The name should be unique within the plugin, and it's recommended to use a short, descriptive name.
|
||||
// - The name is case-sensitive.
|
||||
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
|
||||
callbacks[name] = cb
|
||||
RegisterSchedulerCallback(&callbacks)
|
||||
return &namedSchedulerService{name: name, svc: scheduler.NewSchedulerService()}
|
||||
}
|
||||
|
||||
const zwsp = string('\u200b')
|
||||
|
||||
// namedCallbacks is a map of named scheduler callbacks. The key is the name of the callback, and the value is the callback itself.
|
||||
type namedCallbacks map[string]SchedulerCallback
|
||||
|
||||
func parseKey(key string) (string, string) {
|
||||
parts := strings.SplitN(key, zwsp, 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
|
||||
func (n *namedCallbacks) OnSchedulerCallback(ctx context.Context, req *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) {
|
||||
name, scheduleId := parseKey(req.ScheduleId)
|
||||
cb, exists := callbacks[name]
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
req.ScheduleId = scheduleId
|
||||
return cb.OnSchedulerCallback(ctx, req)
|
||||
}
|
||||
|
||||
// namedSchedulerService is a wrapper around the host scheduler service that prefixes the schedule IDs with the
|
||||
// callback name. It is returned by RegisterNamedSchedulerCallback, and should be used by the plugin to schedule
|
||||
// jobs for the named callback.
|
||||
type namedSchedulerService struct {
|
||||
name string
|
||||
cb SchedulerCallback
|
||||
svc scheduler.SchedulerService
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) makeKey(id string) string {
|
||||
return n.name + zwsp + id
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) mapResponse(resp *scheduler.ScheduleResponse, err error) (*scheduler.ScheduleResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, resp.ScheduleId = parseKey(resp.ScheduleId)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) ScheduleOneTime(ctx context.Context, request *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.mapResponse(n.svc.ScheduleOneTime(ctx, request))
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) ScheduleRecurring(ctx context.Context, request *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.mapResponse(n.svc.ScheduleRecurring(ctx, request))
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.svc.CancelSchedule(ctx, request)
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
|
||||
return n.svc.TimeNow(ctx, request)
|
||||
}
|
||||
7315
plugins/api/api_vtproto.pb.go
Normal file
7315
plugins/api/api_vtproto.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
12
plugins/api/errors.go
Normal file
12
plugins/api/errors.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package api
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
// ErrNotImplemented indicates that the plugin does not implement the requested method.
|
||||
// No logic should be executed by the plugin.
|
||||
ErrNotImplemented = errors.New("plugin:not_implemented")
|
||||
|
||||
// ErrNotFound indicates that the requested resource was not found by the plugin.
|
||||
ErrNotFound = errors.New("plugin:not_found")
|
||||
)
|
||||
159
plugins/base_capability.go
Normal file
159
plugins/base_capability.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
)
|
||||
|
||||
// newBaseCapability creates a new instance of baseCapability with the required parameters.
|
||||
func newBaseCapability[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *baseCapability[S, P] {
|
||||
return &baseCapability[S, P]{
|
||||
wasmPath: wasmPath,
|
||||
id: id,
|
||||
capability: capability,
|
||||
loader: loader,
|
||||
loadFunc: loadFunc,
|
||||
metrics: m,
|
||||
}
|
||||
}
|
||||
|
||||
// LoaderFunc is a generic function type that loads a plugin instance.
|
||||
type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error)
|
||||
|
||||
// baseCapability is a generic base implementation for WASM plugins.
|
||||
// S is the capability interface type and P is the plugin loader type.
|
||||
type baseCapability[S any, P any] struct {
|
||||
wasmPath string
|
||||
id string
|
||||
capability string
|
||||
loader P
|
||||
loadFunc loaderFunc[S, P]
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) PluginID() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) serviceName() string {
|
||||
return w.id + "_" + w.capability
|
||||
}
|
||||
|
||||
func (w *baseCapability[S, P]) getMetrics() metrics.Metrics {
|
||||
return w.metrics
|
||||
}
|
||||
|
||||
// getInstance loads a new plugin instance and returns a cleanup function.
|
||||
func (w *baseCapability[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) {
|
||||
start := time.Now()
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName)
|
||||
|
||||
inst, err := w.loadFunc(ctx, w.loader, w.wasmPath)
|
||||
if err != nil {
|
||||
var zero S
|
||||
return zero, func() {}, fmt.Errorf("baseCapability: failed to load instance for %s: %w", w.serviceName(), err)
|
||||
}
|
||||
// Add context metadata for tracing
|
||||
ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst))
|
||||
log.Trace(ctx, "baseCapability: loaded instance", "elapsed", time.Since(start))
|
||||
return inst, func() {
|
||||
log.Trace(ctx, "baseCapability: finished using instance", "elapsed", time.Since(start))
|
||||
if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok {
|
||||
_ = closer.Close(ctx)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
type wasmPlugin[S any] interface {
|
||||
PluginID() string
|
||||
getInstance(ctx context.Context, methodName string) (S, func(), error)
|
||||
getMetrics() metrics.Metrics
|
||||
}
|
||||
|
||||
func callMethod[S any, R any](ctx context.Context, wp WasmPlugin, methodName string, fn func(inst S) (R, error)) (R, error) {
|
||||
// Add a unique call ID to the context for tracing
|
||||
ctx = log.NewContext(ctx, "callID", id.NewRandom())
|
||||
var r R
|
||||
|
||||
p, ok := wp.(wasmPlugin[S])
|
||||
if !ok {
|
||||
log.Error(ctx, "callMethod: not a wasm plugin", "method", methodName, "pluginID", wp.PluginID())
|
||||
return r, fmt.Errorf("wasm plugin: not a wasm plugin: %s", wp.PluginID())
|
||||
}
|
||||
|
||||
inst, done, err := p.getInstance(ctx, methodName)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
start := time.Now()
|
||||
defer done()
|
||||
r, err = checkErr(fn(inst))
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if !errors.Is(err, api.ErrNotImplemented) {
|
||||
id := p.PluginID()
|
||||
isOk := err == nil
|
||||
metrics := p.getMetrics()
|
||||
if metrics != nil {
|
||||
metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "callMethod: sending metrics", "plugin", id, "method", methodName, "ok", isOk, "elapsed", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
return r, err
|
||||
}
|
||||
|
||||
// errorResponse is an interface that defines a method to retrieve an error message.
|
||||
// It is automatically implemented (generated) by all plugin responses that have an Error field
|
||||
type errorResponse interface {
|
||||
GetError() string
|
||||
}
|
||||
|
||||
// checkErr returns an updated error if the response implements errorResponse and contains an error message.
|
||||
// If the response is nil, it returns the original error. Otherwise, it wraps or creates an error as needed.
|
||||
// It also maps error strings to their corresponding api.Err* constants.
|
||||
func checkErr[T any](resp T, err error) (T, error) {
|
||||
if any(resp) == nil {
|
||||
return resp, mapAPIError(err)
|
||||
}
|
||||
respErr, ok := any(resp).(errorResponse)
|
||||
if ok && respErr.GetError() != "" {
|
||||
respErrMsg := respErr.GetError()
|
||||
respErrErr := errors.New(respErrMsg)
|
||||
mappedErr := mapAPIError(respErrErr)
|
||||
// Check if the error was mapped to an API error (different from the temp error)
|
||||
if errors.Is(mappedErr, api.ErrNotImplemented) || errors.Is(mappedErr, api.ErrNotFound) {
|
||||
// Return the mapped API error instead of wrapping
|
||||
return resp, mappedErr
|
||||
}
|
||||
// For non-API errors, use wrap the original error if it is not nil
|
||||
return resp, errors.Join(respErrErr, err)
|
||||
}
|
||||
return resp, mapAPIError(err)
|
||||
}
|
||||
|
||||
// mapAPIError maps error strings to their corresponding api.Err* constants.
|
||||
// This is needed as errors from plugins may not be of type api.Error, due to serialization/deserialization.
|
||||
func mapAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
switch errStr {
|
||||
case api.ErrNotImplemented.Error():
|
||||
return api.ErrNotImplemented
|
||||
case api.ErrNotFound.Error():
|
||||
return api.ErrNotFound
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
285
plugins/base_capability_test.go
Normal file
285
plugins/base_capability_test.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type nilInstance struct{}
|
||||
|
||||
var _ = Describe("baseCapability", func() {
|
||||
var ctx = context.Background()
|
||||
|
||||
It("should load instance using loadFunc", func() {
|
||||
called := false
|
||||
plugin := &baseCapability[*nilInstance, any]{
|
||||
wasmPath: "",
|
||||
id: "test",
|
||||
capability: "test",
|
||||
loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) {
|
||||
called = true
|
||||
return &nilInstance{}, nil
|
||||
},
|
||||
}
|
||||
inst, done, err := plugin.getInstance(ctx, "test")
|
||||
defer done()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(inst).ToNot(BeNil())
|
||||
Expect(called).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("checkErr", func() {
|
||||
Context("when resp is nil", func() {
|
||||
It("should return nil error when both resp and err are nil", func() {
|
||||
var resp *testErrorResponse
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should return original error unchanged for non-API errors", func() {
|
||||
var resp *testErrorResponse
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented", func() {
|
||||
var resp *testErrorResponse
|
||||
err := errors.New("plugin:not_implemented")
|
||||
|
||||
result, mappedErr := checkErr(resp, err)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(mappedErr).To(Equal(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound", func() {
|
||||
var resp *testErrorResponse
|
||||
err := errors.New("plugin:not_found")
|
||||
|
||||
result, mappedErr := checkErr(resp, err)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(mappedErr).To(Equal(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp is a typed nil that implements errorResponse", func() {
|
||||
It("should not panic and return original error", func() {
|
||||
var resp *testErrorResponse // typed nil
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
// This should not panic
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should handle typed nil with nil error gracefully", func() {
|
||||
var resp *testErrorResponse // typed nil
|
||||
|
||||
// This should not panic
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(BeNil())
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp implements errorResponse with non-empty error", func() {
|
||||
It("should create new error when original error is nil", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin error"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError("plugin error"))
|
||||
})
|
||||
|
||||
It("should wrap original error when both exist", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin error"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that both error messages are present in the joined error
|
||||
errStr := err.Error()
|
||||
Expect(errStr).To(ContainSubstring("plugin error"))
|
||||
Expect(errStr).To(ContainSubstring("original error"))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented when no original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound when no original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_found"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotImplemented even with original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
|
||||
It("should return mapped API error for ErrNotFound even with original error", func() {
|
||||
resp := &testErrorResponse{errorMsg: "plugin:not_found"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp implements errorResponse with empty error", func() {
|
||||
It("should return original error unchanged", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(originalErr))
|
||||
})
|
||||
|
||||
It("should return nil error when both are empty/nil", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should map original API error when response error is empty", func() {
|
||||
resp := &testErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("plugin:not_implemented")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp does not implement errorResponse", func() {
|
||||
It("should return original error unchanged", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(Equal(originalErr))
|
||||
})
|
||||
|
||||
It("should return nil error when original error is nil", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
|
||||
result, err := checkErr(resp, nil)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should map original API error when response doesn't implement errorResponse", func() {
|
||||
resp := &testNonErrorResponse{data: "some data"}
|
||||
originalErr := errors.New("plugin:not_found")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when resp is a value type (not pointer)", func() {
|
||||
It("should handle value types that implement errorResponse", func() {
|
||||
resp := testValueErrorResponse{errorMsg: "value error"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Check that both error messages are present in the joined error
|
||||
errStr := err.Error()
|
||||
Expect(errStr).To(ContainSubstring("value error"))
|
||||
Expect(errStr).To(ContainSubstring("original error"))
|
||||
})
|
||||
|
||||
It("should handle value types with empty error", func() {
|
||||
resp := testValueErrorResponse{errorMsg: ""}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(originalErr))
|
||||
})
|
||||
|
||||
It("should handle value types with API error", func() {
|
||||
resp := testValueErrorResponse{errorMsg: "plugin:not_implemented"}
|
||||
originalErr := errors.New("original error")
|
||||
|
||||
result, err := checkErr(resp, originalErr)
|
||||
|
||||
Expect(result).To(Equal(resp))
|
||||
Expect(err).To(MatchError(api.ErrNotImplemented))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Test helper types
|
||||
type testErrorResponse struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (t *testErrorResponse) GetError() string {
|
||||
if t == nil {
|
||||
return "" // This is what would typically happen with a typed nil
|
||||
}
|
||||
return t.errorMsg
|
||||
}
|
||||
|
||||
type testNonErrorResponse struct {
|
||||
data string
|
||||
}
|
||||
|
||||
type testValueErrorResponse struct {
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (t testValueErrorResponse) GetError() string {
|
||||
return t.errorMsg
|
||||
}
|
||||
145
plugins/discovery.go
Normal file
145
plugins/discovery.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
)
|
||||
|
||||
// PluginDiscoveryEntry represents the result of plugin discovery
|
||||
type PluginDiscoveryEntry struct {
|
||||
ID string // Plugin ID (directory name)
|
||||
Path string // Resolved plugin directory path
|
||||
WasmPath string // Path to the WASM file
|
||||
Manifest *schema.PluginManifest // Loaded manifest (nil if failed)
|
||||
IsSymlink bool // Whether the plugin is a development symlink
|
||||
Error error // Error encountered during discovery
|
||||
}
|
||||
|
||||
// DiscoverPlugins scans the plugins directory and returns information about all discoverable plugins
|
||||
// This shared function eliminates duplication between ScanPlugins and plugin list commands
|
||||
func DiscoverPlugins(pluginsDir string) []PluginDiscoveryEntry {
|
||||
var discoveries []PluginDiscoveryEntry
|
||||
|
||||
entries, err := os.ReadDir(pluginsDir)
|
||||
if err != nil {
|
||||
// Return a single entry with the error
|
||||
return []PluginDiscoveryEntry{{
|
||||
Error: fmt.Errorf("failed to read plugins directory %s: %w", pluginsDir, err),
|
||||
}}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
pluginPath := filepath.Join(pluginsDir, name)
|
||||
|
||||
// Skip hidden files
|
||||
if name[0] == '.' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a directory or symlink
|
||||
info, err := os.Lstat(pluginPath)
|
||||
if err != nil {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
Error: fmt.Errorf("failed to stat entry %s: %w", pluginPath, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
isSymlink := info.Mode()&os.ModeSymlink != 0
|
||||
isDir := info.IsDir()
|
||||
|
||||
// Skip if not a directory or symlink
|
||||
if !isDir && !isSymlink {
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve symlinks
|
||||
pluginDir := pluginPath
|
||||
if isSymlink {
|
||||
targetDir, err := os.Readlink(pluginPath)
|
||||
if err != nil {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
IsSymlink: true,
|
||||
Error: fmt.Errorf("failed to resolve symlink %s: %w", pluginPath, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// If target is a relative path, make it absolute
|
||||
if !filepath.IsAbs(targetDir) {
|
||||
targetDir = filepath.Join(filepath.Dir(pluginPath), targetDir)
|
||||
}
|
||||
|
||||
// Verify that the target is a directory
|
||||
targetInfo, err := os.Stat(targetDir)
|
||||
if err != nil {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
IsSymlink: true,
|
||||
Error: fmt.Errorf("failed to stat symlink target %s: %w", targetDir, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if !targetInfo.IsDir() {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
IsSymlink: true,
|
||||
Error: fmt.Errorf("symlink target is not a directory: %s", targetDir),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
pluginDir = targetDir
|
||||
}
|
||||
|
||||
// Check for WASM file
|
||||
wasmPath := filepath.Join(pluginDir, "plugin.wasm")
|
||||
if _, err := os.Stat(wasmPath); err != nil {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
Path: pluginDir,
|
||||
Error: fmt.Errorf("no plugin.wasm found: %w", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Load manifest
|
||||
manifest, err := LoadManifest(pluginDir)
|
||||
if err != nil {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
Path: pluginDir,
|
||||
Error: fmt.Errorf("failed to load manifest: %w", err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for capabilities
|
||||
if len(manifest.Capabilities) == 0 {
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
Path: pluginDir,
|
||||
Error: fmt.Errorf("no capabilities found in manifest"),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Success!
|
||||
discoveries = append(discoveries, PluginDiscoveryEntry{
|
||||
ID: name,
|
||||
Path: pluginDir,
|
||||
WasmPath: wasmPath,
|
||||
Manifest: manifest,
|
||||
IsSymlink: isSymlink,
|
||||
})
|
||||
}
|
||||
|
||||
return discoveries
|
||||
}
|
||||
402
plugins/discovery_test.go
Normal file
402
plugins/discovery_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("DiscoverPlugins", func() {
|
||||
var tempPluginsDir string
|
||||
|
||||
// Helper to create a valid plugin for discovery testing
|
||||
createValidPlugin := func(name, manifestName, author, version string, capabilities []string) {
|
||||
pluginDir := filepath.Join(tempPluginsDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Copy real WASM file from testdata
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "` + manifestName + `",
|
||||
"version": "` + version + `",
|
||||
"capabilities": [`
|
||||
for i, cap := range capabilities {
|
||||
if i > 0 {
|
||||
manifest += `, `
|
||||
}
|
||||
manifest += `"` + cap + `"`
|
||||
}
|
||||
manifest += `],
|
||||
"author": "` + author + `",
|
||||
"description": "Test Plugin",
|
||||
"website": "https://test.navidrome.org/` + manifestName + `",
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
createManifestOnlyPlugin := func(name string) {
|
||||
pluginDir := filepath.Join(tempPluginsDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "manifest-only",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"author": "Test Author",
|
||||
"description": "Test Plugin",
|
||||
"website": "https://test.navidrome.org/manifest-only",
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
createWasmOnlyPlugin := func(name string) {
|
||||
pluginDir := filepath.Join(tempPluginsDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Copy real WASM file from testdata
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
createInvalidManifestPlugin := func(name string) {
|
||||
pluginDir := filepath.Join(tempPluginsDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Copy real WASM file from testdata
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
|
||||
invalidManifest := `{ "invalid": "json" }`
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidManifest), 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
createEmptyCapabilitiesPlugin := func(name string) {
|
||||
pluginDir := filepath.Join(tempPluginsDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Copy real WASM file from testdata
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "empty-capabilities",
|
||||
"version": "1.0.0",
|
||||
"capabilities": [],
|
||||
"author": "Test Author",
|
||||
"description": "Test Plugin",
|
||||
"website": "https://test.navidrome.org/empty-capabilities",
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-discovery-test-*")
|
||||
DeferCleanup(func() {
|
||||
_ = os.RemoveAll(tempPluginsDir)
|
||||
})
|
||||
})
|
||||
|
||||
Context("Valid plugins", func() {
|
||||
It("should discover valid plugins with all required files", func() {
|
||||
createValidPlugin("test-plugin", "Test Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
createValidPlugin("another-plugin", "Another Plugin", "Another Author", "2.0.0", []string{"Scrobbler"})
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(2))
|
||||
|
||||
// Find each plugin by ID
|
||||
var testPlugin, anotherPlugin *PluginDiscoveryEntry
|
||||
for i := range discoveries {
|
||||
switch discoveries[i].ID {
|
||||
case "test-plugin":
|
||||
testPlugin = &discoveries[i]
|
||||
case "another-plugin":
|
||||
anotherPlugin = &discoveries[i]
|
||||
}
|
||||
}
|
||||
|
||||
Expect(testPlugin).NotTo(BeNil())
|
||||
Expect(testPlugin.Error).To(BeNil())
|
||||
Expect(testPlugin.Manifest.Name).To(Equal("Test Plugin"))
|
||||
Expect(string(testPlugin.Manifest.Capabilities[0])).To(Equal("MetadataAgent"))
|
||||
|
||||
Expect(anotherPlugin).NotTo(BeNil())
|
||||
Expect(anotherPlugin.Error).To(BeNil())
|
||||
Expect(anotherPlugin.Manifest.Name).To(Equal("Another Plugin"))
|
||||
Expect(string(anotherPlugin.Manifest.Capabilities[0])).To(Equal("Scrobbler"))
|
||||
})
|
||||
|
||||
It("should handle plugins with same manifest name in different directories", func() {
|
||||
createValidPlugin("lastfm-official", "lastfm", "Official Author", "1.0.0", []string{"MetadataAgent"})
|
||||
createValidPlugin("lastfm-custom", "lastfm", "Custom Author", "2.0.0", []string{"MetadataAgent"})
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(2))
|
||||
|
||||
// Find each plugin by ID
|
||||
var officialPlugin, customPlugin *PluginDiscoveryEntry
|
||||
for i := range discoveries {
|
||||
switch discoveries[i].ID {
|
||||
case "lastfm-official":
|
||||
officialPlugin = &discoveries[i]
|
||||
case "lastfm-custom":
|
||||
customPlugin = &discoveries[i]
|
||||
}
|
||||
}
|
||||
|
||||
Expect(officialPlugin).NotTo(BeNil())
|
||||
Expect(officialPlugin.Error).To(BeNil())
|
||||
Expect(officialPlugin.Manifest.Name).To(Equal("lastfm"))
|
||||
Expect(officialPlugin.Manifest.Author).To(Equal("Official Author"))
|
||||
|
||||
Expect(customPlugin).NotTo(BeNil())
|
||||
Expect(customPlugin.Error).To(BeNil())
|
||||
Expect(customPlugin.Manifest.Name).To(Equal("lastfm"))
|
||||
Expect(customPlugin.Manifest.Author).To(Equal("Custom Author"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Missing files", func() {
|
||||
It("should report error for plugins missing WASM files", func() {
|
||||
createManifestOnlyPlugin("manifest-only")
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("manifest-only"))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("no plugin.wasm found"))
|
||||
})
|
||||
|
||||
It("should skip directories missing manifest files", func() {
|
||||
createWasmOnlyPlugin("wasm-only")
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("wasm-only"))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Invalid content", func() {
|
||||
It("should report error for invalid manifest JSON", func() {
|
||||
createInvalidManifestPlugin("invalid-manifest")
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("invalid-manifest"))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest"))
|
||||
})
|
||||
|
||||
It("should report error for plugins with empty capabilities", func() {
|
||||
createEmptyCapabilitiesPlugin("empty-capabilities")
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("empty-capabilities"))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("field capabilities length: must be >= 1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Symlinks", func() {
|
||||
It("should discover symlinked plugins correctly", func() {
|
||||
// Create a real plugin directory outside tempPluginsDir
|
||||
realPluginDir, err := os.MkdirTemp("", "navidrome-real-plugin-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
DeferCleanup(func() {
|
||||
_ = os.RemoveAll(realPluginDir)
|
||||
})
|
||||
|
||||
// Create plugin files in the real directory
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "symlinked-plugin",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"author": "Test Author",
|
||||
"description": "Test Plugin",
|
||||
"website": "https://test.navidrome.org/symlinked-plugin",
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
|
||||
// Create symlink
|
||||
symlinkPath := filepath.Join(tempPluginsDir, "symlinked-plugin")
|
||||
Expect(os.Symlink(realPluginDir, symlinkPath)).To(Succeed())
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("symlinked-plugin"))
|
||||
Expect(discoveries[0].Error).To(BeNil())
|
||||
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
||||
Expect(discoveries[0].Path).To(Equal(realPluginDir))
|
||||
Expect(discoveries[0].Manifest.Name).To(Equal("symlinked-plugin"))
|
||||
})
|
||||
|
||||
It("should handle relative symlinks", func() {
|
||||
// Create a real plugin directory in the same parent as tempPluginsDir
|
||||
parentDir := filepath.Dir(tempPluginsDir)
|
||||
realPluginDir := filepath.Join(parentDir, "real-plugin-dir")
|
||||
Expect(os.MkdirAll(realPluginDir, 0755)).To(Succeed())
|
||||
DeferCleanup(func() {
|
||||
_ = os.RemoveAll(realPluginDir)
|
||||
})
|
||||
|
||||
// Create plugin files in the real directory
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "relative-symlinked-plugin",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"author": "Test Author",
|
||||
"description": "Test Plugin",
|
||||
"website": "https://test.navidrome.org/relative-symlinked-plugin",
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
|
||||
// Create relative symlink
|
||||
symlinkPath := filepath.Join(tempPluginsDir, "relative-symlinked-plugin")
|
||||
relativeTarget := "../real-plugin-dir"
|
||||
Expect(os.Symlink(relativeTarget, symlinkPath)).To(Succeed())
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("relative-symlinked-plugin"))
|
||||
Expect(discoveries[0].Error).To(BeNil())
|
||||
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
||||
Expect(discoveries[0].Path).To(Equal(realPluginDir))
|
||||
Expect(discoveries[0].Manifest.Name).To(Equal("relative-symlinked-plugin"))
|
||||
})
|
||||
|
||||
It("should report error for broken symlinks", func() {
|
||||
symlinkPath := filepath.Join(tempPluginsDir, "broken-symlink")
|
||||
nonExistentTarget := "/non/existent/path"
|
||||
Expect(os.Symlink(nonExistentTarget, symlinkPath)).To(Succeed())
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("broken-symlink"))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to stat symlink target"))
|
||||
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should report error for symlinks pointing to files", func() {
|
||||
// Create a regular file
|
||||
regularFile := filepath.Join(tempPluginsDir, "regular-file.txt")
|
||||
Expect(os.WriteFile(regularFile, []byte("content"), 0600)).To(Succeed())
|
||||
|
||||
// Create symlink pointing to the file
|
||||
symlinkPath := filepath.Join(tempPluginsDir, "symlink-to-file")
|
||||
Expect(os.Symlink(regularFile, symlinkPath)).To(Succeed())
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("symlink-to-file"))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("symlink target is not a directory"))
|
||||
Expect(discoveries[0].IsSymlink).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Directory filtering", func() {
|
||||
It("should ignore hidden directories", func() {
|
||||
createValidPlugin(".hidden-plugin", "Hidden Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
createValidPlugin("visible-plugin", "Visible Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("visible-plugin"))
|
||||
})
|
||||
|
||||
It("should ignore regular files", func() {
|
||||
// Create a regular file
|
||||
Expect(os.WriteFile(filepath.Join(tempPluginsDir, "regular-file.txt"), []byte("content"), 0600)).To(Succeed())
|
||||
createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].ID).To(Equal("valid-plugin"))
|
||||
})
|
||||
|
||||
It("should handle mixed valid and invalid plugins", func() {
|
||||
createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
createManifestOnlyPlugin("manifest-only")
|
||||
createInvalidManifestPlugin("invalid-manifest")
|
||||
createValidPlugin("another-valid", "Another Valid", "Test Author", "1.0.0", []string{"Scrobbler"})
|
||||
|
||||
discoveries := DiscoverPlugins(tempPluginsDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(4))
|
||||
|
||||
var validCount int
|
||||
var errorCount int
|
||||
for _, discovery := range discoveries {
|
||||
if discovery.Error == nil {
|
||||
validCount++
|
||||
} else {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
Expect(validCount).To(Equal(2))
|
||||
Expect(errorCount).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Error handling", func() {
|
||||
It("should handle non-existent plugins directory", func() {
|
||||
nonExistentDir := "/non/existent/plugins/dir"
|
||||
|
||||
discoveries := DiscoverPlugins(nonExistentDir)
|
||||
|
||||
Expect(discoveries).To(HaveLen(1))
|
||||
Expect(discoveries[0].Error).To(HaveOccurred())
|
||||
Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to read plugins directory"))
|
||||
})
|
||||
})
|
||||
})
|
||||
27
plugins/examples/Makefile
Normal file
27
plugins/examples/Makefile
Normal file
@@ -0,0 +1,27 @@
|
||||
all: wikimedia coverartarchive crypto-ticker discord-rich-presence subsonicapi-demo
|
||||
|
||||
wikimedia: wikimedia/plugin.wasm
|
||||
coverartarchive: coverartarchive/plugin.wasm
|
||||
crypto-ticker: crypto-ticker/plugin.wasm
|
||||
discord-rich-presence: discord-rich-presence/plugin.wasm
|
||||
subsonicapi-demo: subsonicapi-demo/plugin.wasm
|
||||
|
||||
wikimedia/plugin.wasm: wikimedia/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia
|
||||
|
||||
coverartarchive/plugin.wasm: coverartarchive/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive
|
||||
|
||||
crypto-ticker/plugin.wasm: crypto-ticker/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker
|
||||
|
||||
DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go")
|
||||
discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES)
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/...
|
||||
|
||||
subsonicapi-demo/plugin.wasm: subsonicapi-demo/plugin.go
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./subsonicapi-demo
|
||||
|
||||
clean:
|
||||
rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm \
|
||||
discord-rich-presence/plugin.wasm subsonicapi-demo/plugin.wasm
|
||||
31
plugins/examples/README.md
Normal file
31
plugins/examples/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Plugin Examples
|
||||
|
||||
This directory contains example plugins for Navidrome, intended for demonstration and reference purposes. These plugins are not used in automated tests.
|
||||
|
||||
## Contents
|
||||
|
||||
- `wikimedia/`: Retrieves artist information from Wikidata.
|
||||
- `coverartarchive/`: Fetches album cover images from the Cover Art Archive.
|
||||
- `crypto-ticker/`: Uses websockets to log real-time cryptocurrency prices.
|
||||
- `discord-rich-presence/`: Integrates with Discord Rich Presence to display currently playing tracks on Discord profiles.
|
||||
- `subsonicapi-demo/`: Demonstrates interaction with Navidrome's Subsonic API from a plugin.
|
||||
|
||||
## Building
|
||||
|
||||
To build all example plugins, run:
|
||||
|
||||
```
|
||||
make
|
||||
```
|
||||
|
||||
Or to build a specific plugin:
|
||||
|
||||
```
|
||||
make wikimedia
|
||||
make coverartarchive
|
||||
make crypto-ticker
|
||||
make discord-rich-presence
|
||||
make subsonicapi-demo
|
||||
```
|
||||
|
||||
This will produce the corresponding `plugin.wasm` files in each plugin's directory.
|
||||
34
plugins/examples/coverartarchive/README.md
Normal file
34
plugins/examples/coverartarchive/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Cover Art Archive AlbumMetadataService Plugin
|
||||
|
||||
This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release Group MBID.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements only the `GetAlbumImages` method of the AlbumMetadataService plugin interface.
|
||||
- Returns front cover images for a given release-group MBID.
|
||||
- Returns `not found` if no MBID is provided or no images are found.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.24 or newer (with WASI support)
|
||||
- The Navidrome repository (with generated plugin API code in `plugins/api`)
|
||||
|
||||
## How to Compile
|
||||
|
||||
To build the WASM plugin, run the following command from the project root:
|
||||
|
||||
```sh
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugins/testdata/coverartarchive/plugin.wasm ./plugins/testdata/coverartarchive
|
||||
```
|
||||
|
||||
This will produce `plugin.wasm` in this directory.
|
||||
|
||||
## Usage
|
||||
|
||||
- The plugin can be loaded by Navidrome for integration and end-to-end tests of the plugin system.
|
||||
- It is intended for testing and development purposes only.
|
||||
|
||||
## API Reference
|
||||
|
||||
- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API)
|
||||
- This plugin uses the endpoint: `https://coverartarchive.org/release-group/{mbid}`
|
||||
19
plugins/examples/coverartarchive/manifest.json
Normal file
19
plugins/examples/coverartarchive/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "coverartarchive",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Album cover art from the Cover Art Archive",
|
||||
"website": "https://coverartarchive.org",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To fetch album cover art from the Cover Art Archive API",
|
||||
"allowedUrls": {
|
||||
"https://coverartarchive.org": ["GET"],
|
||||
"https://*.archive.org": ["GET"]
|
||||
},
|
||||
"allowLocalNetwork": false
|
||||
}
|
||||
}
|
||||
}
|
||||
151
plugins/examples/coverartarchive/plugin.go
Normal file
151
plugins/examples/coverartarchive/plugin.go
Normal file
@@ -0,0 +1,151 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
)
|
||||
|
||||
type CoverArtArchiveAgent struct{}
|
||||
|
||||
var ErrNotFound = api.ErrNotFound
|
||||
|
||||
type caaImage struct {
|
||||
Image string `json:"image"`
|
||||
Front bool `json:"front"`
|
||||
Types []string `json:"types"`
|
||||
Thumbnails map[string]string `json:"thumbnails"`
|
||||
}
|
||||
|
||||
var client = http.NewHttpService()
|
||||
|
||||
func (CoverArtArchiveAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) {
|
||||
if req.Mbid == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
url := "https://coverartarchive.org/release/" + req.Mbid
|
||||
resp, err := client.Get(ctx, &http.HttpRequest{Url: url, TimeoutMs: 5000})
|
||||
if err != nil || resp.Status != 200 {
|
||||
log.Printf("[CAA] Error getting album images from CoverArtArchive (status: %d): %v", resp.Status, err)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
images, err := extractFrontImages(resp.Body)
|
||||
if err != nil || len(images) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &api.AlbumImagesResponse{Images: images}, nil
|
||||
}
|
||||
|
||||
func extractFrontImages(body []byte) ([]*api.ExternalImage, error) {
|
||||
var data struct {
|
||||
Images []caaImage `json:"images"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := findFrontImage(data.Images)
|
||||
if img == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return buildImageList(img), nil
|
||||
}
|
||||
|
||||
func findFrontImage(images []caaImage) *caaImage {
|
||||
for i, img := range images {
|
||||
if img.Front {
|
||||
return &images[i]
|
||||
}
|
||||
}
|
||||
for i, img := range images {
|
||||
for _, t := range img.Types {
|
||||
if t == "Front" {
|
||||
return &images[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(images) > 0 {
|
||||
return &images[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildImageList(img *caaImage) []*api.ExternalImage {
|
||||
var images []*api.ExternalImage
|
||||
// First, try numeric sizes only
|
||||
for sizeStr, url := range img.Thumbnails {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
size := 0
|
||||
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil {
|
||||
images = append(images, &api.ExternalImage{Url: url, Size: int32(size)})
|
||||
}
|
||||
}
|
||||
// If no numeric sizes, fallback to large/small
|
||||
if len(images) == 0 {
|
||||
for sizeStr, url := range img.Thumbnails {
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
var size int
|
||||
switch sizeStr {
|
||||
case "large":
|
||||
size = 500
|
||||
case "small":
|
||||
size = 250
|
||||
default:
|
||||
continue
|
||||
}
|
||||
images = append(images, &api.ExternalImage{Url: url, Size: int32(size)})
|
||||
}
|
||||
}
|
||||
if len(images) == 0 && img.Image != "" {
|
||||
images = append(images, &api.ExternalImage{Url: img.Image, Size: 0})
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
func (CoverArtArchiveAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) {
|
||||
return nil, api.ErrNotImplemented
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[CAA] ")
|
||||
|
||||
api.RegisterMetadataAgent(CoverArtArchiveAgent{})
|
||||
}
|
||||
53
plugins/examples/crypto-ticker/README.md
Normal file
53
plugins/examples/crypto-ticker/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Crypto Ticker Plugin
|
||||
|
||||
This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase.
|
||||
|
||||
## Features
|
||||
|
||||
- Connects to Coinbase WebSocket API to receive real-time ticker updates
|
||||
- Configurable to track multiple cryptocurrency pairs
|
||||
- Implements WebSocketCallback and LifecycleManagement interfaces
|
||||
- Automatically reconnects on connection loss
|
||||
- Displays price, best bid, best ask, and 24-hour percentage change
|
||||
|
||||
## Configuration
|
||||
|
||||
In your `navidrome.toml` file, add:
|
||||
|
||||
```toml
|
||||
[PluginConfig.crypto-ticker]
|
||||
tickers = "BTC,ETH,SOL,MATIC"
|
||||
```
|
||||
|
||||
- `tickers` is a comma-separated list of cryptocurrency symbols
|
||||
- The plugin will append `-USD` to any symbol without a trading pair specified
|
||||
|
||||
## How it Works
|
||||
|
||||
- The plugin connects to Coinbase's WebSocket API upon initialization
|
||||
- It subscribes to ticker updates for the configured cryptocurrencies
|
||||
- Incoming ticker data is processed and logged
|
||||
- On connection loss, it automatically attempts to reconnect (TODO)
|
||||
|
||||
## Building
|
||||
|
||||
To build the plugin to WASM:
|
||||
|
||||
```
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Copy the resulting `plugin.wasm` and create a `manifest.json` file in your Navidrome plugins folder under a `crypto-ticker` directory.
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
CRYPTO TICKER: BTC-USD Price: 65432.50 Best Bid: 65431.25 Best Ask: 65433.75 24h Change: 2.75%
|
||||
CRYPTO TICKER: ETH-USD Price: 3456.78 Best Bid: 3455.90 Best Ask: 3457.80 24h Change: 1.25%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For more details, see the source code in `plugin.go`.
|
||||
25
plugins/examples/crypto-ticker/manifest.json
Normal file
25
plugins/examples/crypto-ticker/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "crypto-ticker",
|
||||
"author": "Navidrome Plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
|
||||
"capabilities": [
|
||||
"WebSocketCallback",
|
||||
"LifecycleManagement",
|
||||
"SchedulerCallback"
|
||||
],
|
||||
"permissions": {
|
||||
"config": {
|
||||
"reason": "To read API configuration and WebSocket endpoint settings"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule periodic reconnection attempts and status updates"
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices",
|
||||
"allowedUrls": ["wss://ws-feed.exchange.coinbase.com"],
|
||||
"allowLocalNetwork": false
|
||||
}
|
||||
}
|
||||
}
|
||||
304
plugins/examples/crypto-ticker/plugin.go
Normal file
304
plugins/examples/crypto-ticker/plugin.go
Normal file
@@ -0,0 +1,304 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/config"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// Coinbase WebSocket API endpoint
|
||||
coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com"
|
||||
|
||||
// Connection ID for our WebSocket connection
|
||||
connectionID = "crypto-ticker-connection"
|
||||
|
||||
// ID for the reconnection schedule
|
||||
reconnectScheduleID = "crypto-ticker-reconnect"
|
||||
)
|
||||
|
||||
var (
|
||||
// Store ticker symbols from the configuration
|
||||
tickers []string
|
||||
)
|
||||
|
||||
// WebSocketService instance used to manage WebSocket connections and communication.
|
||||
var wsService = websocket.NewWebSocketService()
|
||||
|
||||
// ConfigService instance for accessing plugin configuration.
|
||||
var configService = config.NewConfigService()
|
||||
|
||||
// SchedulerService instance for scheduling tasks.
|
||||
var schedService = scheduler.NewSchedulerService()
|
||||
|
||||
// CryptoTickerPlugin implements WebSocketCallback, LifecycleManagement, and SchedulerCallback interfaces
|
||||
type CryptoTickerPlugin struct{}
|
||||
|
||||
// Coinbase subscription message structure
|
||||
type CoinbaseSubscription struct {
|
||||
Type string `json:"type"`
|
||||
ProductIDs []string `json:"product_ids"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
// Coinbase ticker message structure
|
||||
type CoinbaseTicker struct {
|
||||
Type string `json:"type"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
ProductID string `json:"product_id"`
|
||||
Price string `json:"price"`
|
||||
Open24h string `json:"open_24h"`
|
||||
Volume24h string `json:"volume_24h"`
|
||||
Low24h string `json:"low_24h"`
|
||||
High24h string `json:"high_24h"`
|
||||
Volume30d string `json:"volume_30d"`
|
||||
BestBid string `json:"best_bid"`
|
||||
BestAsk string `json:"best_ask"`
|
||||
Side string `json:"side"`
|
||||
Time string `json:"time"`
|
||||
TradeID int `json:"trade_id"`
|
||||
LastSize string `json:"last_size"`
|
||||
}
|
||||
|
||||
// OnInit is called when the plugin is loaded
|
||||
func (CryptoTickerPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
|
||||
log.Printf("Crypto Ticker Plugin initializing...")
|
||||
|
||||
// Check if ticker configuration exists
|
||||
tickerConfig, ok := req.Config["tickers"]
|
||||
if !ok {
|
||||
return &api.InitResponse{Error: "Missing 'tickers' configuration"}, nil
|
||||
}
|
||||
|
||||
// Parse ticker symbols
|
||||
tickers := parseTickerSymbols(tickerConfig)
|
||||
log.Printf("Configured tickers: %v", tickers)
|
||||
|
||||
// Connect to WebSocket and subscribe to tickers
|
||||
err := connectAndSubscribe(ctx, tickers)
|
||||
if err != nil {
|
||||
return &api.InitResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
return &api.InitResponse{}, nil
|
||||
}
|
||||
|
||||
// Helper function to parse ticker symbols from a comma-separated string
|
||||
func parseTickerSymbols(tickerConfig string) []string {
|
||||
tickers := strings.Split(tickerConfig, ",")
|
||||
for i, ticker := range tickers {
|
||||
tickers[i] = strings.TrimSpace(ticker)
|
||||
|
||||
// Add -USD suffix if not present
|
||||
if !strings.Contains(tickers[i], "-") {
|
||||
tickers[i] = tickers[i] + "-USD"
|
||||
}
|
||||
}
|
||||
return tickers
|
||||
}
|
||||
|
||||
// Helper function to connect to WebSocket and subscribe to tickers
|
||||
func connectAndSubscribe(ctx context.Context, tickers []string) error {
|
||||
// Connect to the WebSocket API
|
||||
_, err := wsService.Connect(ctx, &websocket.ConnectRequest{
|
||||
Url: coinbaseWSEndpoint,
|
||||
ConnectionId: connectionID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to connect to Coinbase WebSocket API: %v", err)
|
||||
return fmt.Errorf("WebSocket connection error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Connected to Coinbase WebSocket API")
|
||||
|
||||
// Subscribe to ticker channel for the configured symbols
|
||||
subscription := CoinbaseSubscription{
|
||||
Type: "subscribe",
|
||||
ProductIDs: tickers,
|
||||
Channels: []string{"ticker"},
|
||||
}
|
||||
|
||||
subscriptionJSON, err := json.Marshal(subscription)
|
||||
if err != nil {
|
||||
log.Printf("Failed to marshal subscription message: %v", err)
|
||||
return fmt.Errorf("JSON marshal error: %v", err)
|
||||
}
|
||||
|
||||
// Send subscription message
|
||||
_, err = wsService.SendText(ctx, &websocket.SendTextRequest{
|
||||
ConnectionId: connectionID,
|
||||
Message: string(subscriptionJSON),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to send subscription message: %v", err)
|
||||
return fmt.Errorf("WebSocket send error: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Subscription message sent to Coinbase WebSocket API")
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnTextMessage is called when a text message is received from the WebSocket
|
||||
func (CryptoTickerPlugin) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) {
|
||||
// Only process messages from our connection
|
||||
if req.ConnectionId != connectionID {
|
||||
log.Printf("Received message from unexpected connection: %s", req.ConnectionId)
|
||||
return &api.OnTextMessageResponse{}, nil
|
||||
}
|
||||
|
||||
// Try to parse as a ticker message
|
||||
var ticker CoinbaseTicker
|
||||
err := json.Unmarshal([]byte(req.Message), &ticker)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse ticker message: %v", err)
|
||||
return &api.OnTextMessageResponse{}, nil
|
||||
}
|
||||
|
||||
// If the message is not a ticker or has an error, just log it
|
||||
if ticker.Type != "ticker" {
|
||||
// This could be subscription confirmation or other messages
|
||||
log.Printf("Received non-ticker message: %s", req.Message)
|
||||
return &api.OnTextMessageResponse{}, nil
|
||||
}
|
||||
|
||||
// Format and print ticker information
|
||||
log.Printf("CRYPTO TICKER: %s Price: %s Best Bid: %s Best Ask: %s 24h Change: %s%%\n",
|
||||
ticker.ProductID,
|
||||
ticker.Price,
|
||||
ticker.BestBid,
|
||||
ticker.BestAsk,
|
||||
calculatePercentChange(ticker.Open24h, ticker.Price),
|
||||
)
|
||||
|
||||
return &api.OnTextMessageResponse{}, nil
|
||||
}
|
||||
|
||||
// OnBinaryMessage is called when a binary message is received
|
||||
func (CryptoTickerPlugin) OnBinaryMessage(ctx context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) {
|
||||
// Not expected from Coinbase WebSocket API
|
||||
return &api.OnBinaryMessageResponse{}, nil
|
||||
}
|
||||
|
||||
// OnError is called when an error occurs on the WebSocket connection
|
||||
func (CryptoTickerPlugin) OnError(ctx context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) {
|
||||
log.Printf("WebSocket error: %s", req.Error)
|
||||
return &api.OnErrorResponse{}, nil
|
||||
}
|
||||
|
||||
// OnClose is called when the WebSocket connection is closed
|
||||
func (CryptoTickerPlugin) OnClose(ctx context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) {
|
||||
log.Printf("WebSocket connection closed with code %d: %s", req.Code, req.Reason)
|
||||
|
||||
// Try to reconnect if this is our connection
|
||||
if req.ConnectionId == connectionID {
|
||||
log.Printf("Scheduling reconnection attempts every 2 seconds...")
|
||||
|
||||
// Create a recurring schedule to attempt reconnection every 2 seconds
|
||||
resp, err := schedService.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
|
||||
// Run every 2 seconds using cron expression
|
||||
CronExpression: "*/2 * * * * *",
|
||||
ScheduleId: reconnectScheduleID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to schedule reconnection attempts: %v", err)
|
||||
} else {
|
||||
log.Printf("Reconnection schedule created with ID: %s", resp.ScheduleId)
|
||||
}
|
||||
}
|
||||
|
||||
return &api.OnCloseResponse{}, nil
|
||||
}
|
||||
|
||||
// OnSchedulerCallback is called when a scheduled event triggers
|
||||
func (CryptoTickerPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
|
||||
// Only handle our reconnection schedule
|
||||
if req.ScheduleId != reconnectScheduleID {
|
||||
log.Printf("Received callback for unknown schedule: %s", req.ScheduleId)
|
||||
return &api.SchedulerCallbackResponse{}, nil
|
||||
}
|
||||
|
||||
log.Printf("Attempting to reconnect to Coinbase WebSocket API...")
|
||||
|
||||
// Get the current ticker configuration
|
||||
configResp, err := configService.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get plugin configuration: %v", err)
|
||||
return &api.SchedulerCallbackResponse{Error: fmt.Sprintf("Config error: %v", err)}, nil
|
||||
}
|
||||
|
||||
// Check if ticker configuration exists
|
||||
tickerConfig, ok := configResp.Config["tickers"]
|
||||
if !ok {
|
||||
log.Printf("Missing 'tickers' configuration")
|
||||
return &api.SchedulerCallbackResponse{Error: "Missing 'tickers' configuration"}, nil
|
||||
}
|
||||
|
||||
// Parse ticker symbols
|
||||
tickers := parseTickerSymbols(tickerConfig)
|
||||
log.Printf("Reconnecting with tickers: %v", tickers)
|
||||
|
||||
// Try to connect and subscribe
|
||||
err = connectAndSubscribe(ctx, tickers)
|
||||
if err != nil {
|
||||
log.Printf("Reconnection attempt failed: %v", err)
|
||||
return &api.SchedulerCallbackResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Successfully reconnected, cancel the reconnection schedule
|
||||
_, err = schedService.CancelSchedule(ctx, &scheduler.CancelRequest{
|
||||
ScheduleId: reconnectScheduleID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to cancel reconnection schedule: %v", err)
|
||||
} else {
|
||||
log.Printf("Reconnection schedule canceled after successful reconnection")
|
||||
}
|
||||
|
||||
return &api.SchedulerCallbackResponse{}, nil
|
||||
}
|
||||
|
||||
// Helper function to calculate percent change
|
||||
func calculatePercentChange(open, current string) string {
|
||||
var openFloat, currentFloat float64
|
||||
_, err := fmt.Sscanf(open, "%f", &openFloat)
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
_, err = fmt.Sscanf(current, "%f", ¤tFloat)
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
if openFloat == 0 {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
change := ((currentFloat - openFloat) / openFloat) * 100
|
||||
return fmt.Sprintf("%.2f", change)
|
||||
}
|
||||
|
||||
// Required by Go WASI build
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line, prepend [Crypto]
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Crypto] ")
|
||||
|
||||
api.RegisterWebSocketCallback(CryptoTickerPlugin{})
|
||||
api.RegisterLifecycleManagement(CryptoTickerPlugin{})
|
||||
api.RegisterSchedulerCallback(CryptoTickerPlugin{})
|
||||
}
|
||||
88
plugins/examples/discord-rich-presence/README.md
Normal file
88
plugins/examples/discord-rich-presence/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Discord Rich Presence Plugin
|
||||
|
||||
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time
|
||||
connection to an external service while remaining completely stateless. This plugin is based on the
|
||||
[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality.
|
||||
|
||||
**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the
|
||||
Navidrome configuration file, which is not secure, and may be against Discord's terms of service.
|
||||
Use it at your own risk.**
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin exposes three capabilities:
|
||||
|
||||
- **Scrobbler** – receives `NowPlaying` notifications from Navidrome
|
||||
- **WebSocketCallback** – handles Discord gateway messages
|
||||
- **SchedulerCallback** – used to clear presence and send periodic heartbeats
|
||||
|
||||
It relies on several host services declared in `manifest.json`:
|
||||
|
||||
- `http` – queries Discord API endpoints
|
||||
- `websocket` – maintains gateway connections
|
||||
- `scheduler` – schedules heartbeats and presence cleanup
|
||||
- `cache` – stores sequence numbers for heartbeats
|
||||
- `config` – retrieves the plugin configuration on each call
|
||||
- `artwork` – resolves track artwork URLs
|
||||
|
||||
## Architecture
|
||||
|
||||
Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the
|
||||
scheduler service:
|
||||
|
||||
```go
|
||||
api.RegisterScrobbler(plugin)
|
||||
api.RegisterWebSocketCallback(plugin.rpc)
|
||||
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
|
||||
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
|
||||
```
|
||||
|
||||
When `NowPlaying` is invoked the plugin:
|
||||
|
||||
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
|
||||
2. Connects to Discord using `WebSocketService` if no connection exists.
|
||||
3. Sends the activity payload with track details and artwork.
|
||||
4. Schedules a one‑time callback to clear the presence after the track finishes.
|
||||
|
||||
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in
|
||||
`CacheService` to remain available across plugin instances.
|
||||
|
||||
The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached.
|
||||
|
||||
```go
|
||||
// The plugin is stateless, we need to load the configuration every time
|
||||
clientID, users, err := d.getConfig(ctx)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following to `navidrome.toml` and adjust for your tokens:
|
||||
|
||||
```toml
|
||||
[PluginConfig.discord-rich-presence]
|
||||
ClientID = "123456789012345678"
|
||||
Users = "alice:token123,bob:token456"
|
||||
```
|
||||
|
||||
- `clientid` is your Discord application ID
|
||||
- `users` is a comma‑separated list of `username:token` pairs used for authorization
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/...
|
||||
```
|
||||
|
||||
Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins
|
||||
directory.
|
||||
|
||||
## Stateless Operation
|
||||
|
||||
Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it
|
||||
afterwards.
|
||||
|
||||
To work within this model the plugin stores no in-memory state. Connections are keyed by user name inside the host
|
||||
services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every
|
||||
method call.
|
||||
|
||||
For more implementation details see `plugin.go` and `rpc.go`.
|
||||
35
plugins/examples/discord-rich-presence/manifest.json
Normal file
35
plugins/examples/discord-rich-presence/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "discord-rich-presence",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord Rich Presence integration for Navidrome",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
|
||||
"capabilities": ["Scrobbler", "SchedulerCallback", "WebSocketCallback"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"allowedUrls": {
|
||||
"https://discord.com/api/*": ["GET", "POST"]
|
||||
},
|
||||
"allowLocalNetwork": false
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To maintain real-time connection with Discord gateway",
|
||||
"allowedUrls": ["wss://gateway.discord.gg"],
|
||||
"allowLocalNetwork": false
|
||||
},
|
||||
"config": {
|
||||
"reason": "To access plugin configuration (client ID and user tokens)"
|
||||
},
|
||||
"cache": {
|
||||
"reason": "To store connection state and sequence numbers"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule heartbeat messages and activity clearing"
|
||||
},
|
||||
"artwork": {
|
||||
"reason": "To get track artwork URLs for rich presence display"
|
||||
}
|
||||
}
|
||||
}
|
||||
186
plugins/examples/discord-rich-presence/plugin.go
Normal file
186
plugins/examples/discord-rich-presence/plugin.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/artwork"
|
||||
"github.com/navidrome/navidrome/plugins/host/cache"
|
||||
"github.com/navidrome/navidrome/plugins/host/config"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type DiscordRPPlugin struct {
|
||||
rpc *discordRPC
|
||||
cfg config.ConfigService
|
||||
artwork artwork.ArtworkService
|
||||
sched scheduler.SchedulerService
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) {
|
||||
// Get plugin configuration
|
||||
_, users, err := d.getConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check user authorization: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user has a Discord token configured
|
||||
_, authorized := users[req.Username]
|
||||
log.Printf("IsAuthorized for user %s: %v", req.Username, authorized)
|
||||
return &api.ScrobblerIsAuthorizedResponse{
|
||||
Authorized: authorized,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) {
|
||||
log.Printf("Setting presence for user %s, track: %s", request.Username, request.Track.Name)
|
||||
|
||||
// The plugin is stateless, we need to load the configuration every time
|
||||
clientID, users, err := d.getConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
// Check if the user has a Discord token configured
|
||||
userToken, authorized := users[request.Username]
|
||||
if !authorized {
|
||||
return nil, fmt.Errorf("user '%s' not authorized", request.Username)
|
||||
}
|
||||
|
||||
// Make sure we have a connection
|
||||
if err := d.rpc.connect(ctx, request.Username, userToken); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Discord: %w", err)
|
||||
}
|
||||
|
||||
// Cancel any existing completion schedule
|
||||
if resp, _ := d.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: request.Username}); resp.Error != "" {
|
||||
log.Printf("Ignoring failure to cancel schedule: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Send activity update
|
||||
if err := d.rpc.sendActivity(ctx, clientID, request.Username, userToken, activity{
|
||||
Application: clientID,
|
||||
Name: "Navidrome",
|
||||
Type: 2,
|
||||
Details: request.Track.Name,
|
||||
State: d.getArtistList(request.Track),
|
||||
Timestamps: activityTimestamps{
|
||||
Start: (request.Timestamp - int64(request.Track.Position)) * 1000,
|
||||
End: (request.Timestamp - int64(request.Track.Position) + int64(request.Track.Length)) * 1000,
|
||||
},
|
||||
Assets: activityAssets{
|
||||
LargeImage: d.imageURL(ctx, request),
|
||||
LargeText: request.Track.Album,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to send activity: %w", err)
|
||||
}
|
||||
|
||||
// Schedule a timer to clear the activity after the track completes
|
||||
_, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{
|
||||
ScheduleId: request.Username,
|
||||
DelaySeconds: request.Track.Length - request.Track.Position + 5,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to schedule completion timer: %w", err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) imageURL(ctx context.Context, request *api.ScrobblerNowPlayingRequest) string {
|
||||
imageResp, _ := d.artwork.GetTrackUrl(ctx, &artwork.GetArtworkUrlRequest{Id: request.Track.Id, Size: 300})
|
||||
imageURL := imageResp.Url
|
||||
if strings.HasPrefix(imageURL, "http://localhost") {
|
||||
return ""
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) getArtistList(track *api.TrackInfo) string {
|
||||
return strings.Join(slice.Map(track.Artists, func(a *api.Artist) string { return a.Name }), " • ")
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) Scrobble(context.Context, *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) getConfig(ctx context.Context) (string, map[string]string, error) {
|
||||
const (
|
||||
clientIDKey = "clientid"
|
||||
usersKey = "users"
|
||||
)
|
||||
confResp, err := d.cfg.GetPluginConfig(ctx, &config.GetPluginConfigRequest{})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("unable to load config: %w", err)
|
||||
}
|
||||
conf := confResp.GetConfig()
|
||||
if len(conf) < 1 {
|
||||
log.Print("missing configuration")
|
||||
return "", nil, nil
|
||||
}
|
||||
clientID := conf[clientIDKey]
|
||||
if clientID == "" {
|
||||
log.Printf("missing ClientID: %v", conf)
|
||||
return "", nil, nil
|
||||
}
|
||||
cfgUsers := conf[usersKey]
|
||||
if len(cfgUsers) == 0 {
|
||||
log.Print("no users configured")
|
||||
return "", nil, nil
|
||||
}
|
||||
users := map[string]string{}
|
||||
for _, user := range strings.Split(cfgUsers, ",") {
|
||||
tuple := strings.Split(user, ":")
|
||||
if len(tuple) != 2 {
|
||||
return clientID, nil, fmt.Errorf("invalid user config: %s", user)
|
||||
}
|
||||
users[tuple[0]] = tuple[1]
|
||||
}
|
||||
return clientID, users, nil
|
||||
}
|
||||
|
||||
func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
|
||||
log.Printf("Removing presence for user %s", req.ScheduleId)
|
||||
if err := d.rpc.clearActivity(ctx, req.ScheduleId); err != nil {
|
||||
return nil, fmt.Errorf("failed to clear activity: %w", err)
|
||||
}
|
||||
log.Printf("Disconnecting user %s", req.ScheduleId)
|
||||
if err := d.rpc.disconnect(ctx, req.ScheduleId); err != nil {
|
||||
return nil, fmt.Errorf("failed to disconnect from Discord: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Creates a new instance of the DiscordRPPlugin, with all host services as dependencies
|
||||
var plugin = &DiscordRPPlugin{
|
||||
cfg: config.NewConfigService(),
|
||||
artwork: artwork.NewArtworkService(),
|
||||
rpc: &discordRPC{
|
||||
ws: websocket.NewWebSocketService(),
|
||||
web: http.NewHttpService(),
|
||||
mem: cache.NewCacheService(),
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line, prepend [Discord]
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Discord] ")
|
||||
|
||||
// Register plugin capabilities
|
||||
api.RegisterScrobbler(plugin)
|
||||
api.RegisterWebSocketCallback(plugin.rpc)
|
||||
|
||||
// Register named scheduler callbacks, and get the scheduler service for each
|
||||
plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin)
|
||||
plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc)
|
||||
}
|
||||
|
||||
func main() {}
|
||||
402
plugins/examples/discord-rich-presence/rpc.go
Normal file
402
plugins/examples/discord-rich-presence/rpc.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/cache"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
)
|
||||
|
||||
type discordRPC struct {
|
||||
ws websocket.WebSocketService
|
||||
web http.HttpService
|
||||
mem cache.CacheService
|
||||
sched scheduler.SchedulerService
|
||||
}
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
const (
|
||||
heartbeatOpCode = 1 // Heartbeat operation code
|
||||
gateOpCode = 2 // Identify operation code
|
||||
presenceOpCode = 3 // Presence update operation code
|
||||
)
|
||||
|
||||
const (
|
||||
heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||
defaultImage = "https://i.imgur.com/hb3XPzA.png"
|
||||
)
|
||||
|
||||
// Activity is a struct that represents an activity in Discord.
|
||||
type activity struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Details string `json:"details"`
|
||||
State string `json:"state"`
|
||||
Application string `json:"application_id"`
|
||||
Timestamps activityTimestamps `json:"timestamps"`
|
||||
Assets activityAssets `json:"assets"`
|
||||
}
|
||||
|
||||
type activityTimestamps struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
}
|
||||
|
||||
type activityAssets struct {
|
||||
LargeImage string `json:"large_image"`
|
||||
LargeText string `json:"large_text"`
|
||||
}
|
||||
|
||||
// PresencePayload is a struct that represents a presence update in Discord.
|
||||
type presencePayload struct {
|
||||
Activities []activity `json:"activities"`
|
||||
Since int64 `json:"since"`
|
||||
Status string `json:"status"`
|
||||
Afk bool `json:"afk"`
|
||||
}
|
||||
|
||||
// IdentifyPayload is a struct that represents an identify payload in Discord.
|
||||
type identifyPayload struct {
|
||||
Token string `json:"token"`
|
||||
Intents int `json:"intents"`
|
||||
Properties identifyProperties `json:"properties"`
|
||||
}
|
||||
|
||||
type identifyProperties struct {
|
||||
OS string `json:"os"`
|
||||
Browser string `json:"browser"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
func (r *discordRPC) processImage(ctx context.Context, imageURL string, clientID string, token string) (string, error) {
|
||||
return r.processImageWithFallback(ctx, imageURL, clientID, token, false)
|
||||
}
|
||||
|
||||
func (r *discordRPC) processImageWithFallback(ctx context.Context, imageURL string, clientID string, token string, isDefaultImage bool) (string, error) {
|
||||
// Check if context is canceled
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", fmt.Errorf("context canceled: %w", err)
|
||||
}
|
||||
|
||||
if imageURL == "" {
|
||||
if isDefaultImage {
|
||||
// We're already processing the default image and it's empty, return error
|
||||
return "", fmt.Errorf("default image URL is empty")
|
||||
}
|
||||
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(imageURL, "mp:") {
|
||||
return imageURL, nil
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
|
||||
cacheResp, _ := r.mem.GetString(ctx, &cache.GetRequest{Key: cacheKey})
|
||||
if cacheResp.Exists {
|
||||
log.Printf("Cache hit for image URL: %s", imageURL)
|
||||
return cacheResp.Value, nil
|
||||
}
|
||||
|
||||
resp, _ := r.web.Post(ctx, &http.HttpRequest{
|
||||
Url: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID),
|
||||
Headers: map[string]string{
|
||||
"Authorization": token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
Body: fmt.Appendf(nil, `{"urls":[%q]}`, imageURL),
|
||||
})
|
||||
|
||||
// Handle HTTP error responses
|
||||
if resp.Status >= 400 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to process default image: HTTP %d %s", resp.Status, resp.Error)
|
||||
}
|
||||
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
if isDefaultImage {
|
||||
// If we're already processing the default image and it fails, return error
|
||||
return "", fmt.Errorf("failed to process default image: %s", resp.Error)
|
||||
}
|
||||
// Try with default image
|
||||
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
var data []map[string]string
|
||||
if err := json.Unmarshal(resp.Body, &data); err != nil {
|
||||
if isDefaultImage {
|
||||
// If we're already processing the default image and it fails, return error
|
||||
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
|
||||
}
|
||||
// Try with default image
|
||||
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
if isDefaultImage {
|
||||
// If we're already processing the default image and it fails, return error
|
||||
return "", fmt.Errorf("no data returned for default image")
|
||||
}
|
||||
// Try with default image
|
||||
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
image := data[0]["external_asset_path"]
|
||||
if image == "" {
|
||||
if isDefaultImage {
|
||||
// If we're already processing the default image and it fails, return error
|
||||
return "", fmt.Errorf("empty external_asset_path for default image")
|
||||
}
|
||||
// Try with default image
|
||||
return r.processImageWithFallback(ctx, defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
processedImage := fmt.Sprintf("mp:%s", image)
|
||||
|
||||
// Cache the processed image URL
|
||||
var ttl = 4 * time.Hour // 4 hours for regular images
|
||||
if isDefaultImage {
|
||||
ttl = 48 * time.Hour // 48 hours for default image
|
||||
}
|
||||
|
||||
_, _ = r.mem.SetString(ctx, &cache.SetStringRequest{
|
||||
Key: cacheKey,
|
||||
Value: processedImage,
|
||||
TtlSeconds: int64(ttl.Seconds()),
|
||||
})
|
||||
|
||||
log.Printf("Cached processed image URL for %s (TTL: %s seconds)", imageURL, ttl)
|
||||
|
||||
return processedImage, nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) sendActivity(ctx context.Context, clientID, username, token string, data activity) error {
|
||||
log.Printf("Sending activity to for user %s: %#v", username, data)
|
||||
|
||||
processedImage, err := r.processImage(ctx, data.Assets.LargeImage, clientID, token)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process image for user %s, continuing without image: %v", username, err)
|
||||
// Clear the image and continue without it
|
||||
data.Assets.LargeImage = ""
|
||||
} else {
|
||||
log.Printf("Processed image for URL %s: %s", data.Assets.LargeImage, processedImage)
|
||||
data.Assets.LargeImage = processedImage
|
||||
}
|
||||
|
||||
presence := presencePayload{
|
||||
Activities: []activity{data},
|
||||
Status: "dnd",
|
||||
Afk: false,
|
||||
}
|
||||
return r.sendMessage(ctx, username, presenceOpCode, presence)
|
||||
}
|
||||
|
||||
func (r *discordRPC) clearActivity(ctx context.Context, username string) error {
|
||||
log.Printf("Clearing activity for user %s", username)
|
||||
return r.sendMessage(ctx, username, presenceOpCode, presencePayload{})
|
||||
}
|
||||
|
||||
func (r *discordRPC) sendMessage(ctx context.Context, username string, opCode int, payload any) error {
|
||||
message := map[string]any{
|
||||
"op": opCode,
|
||||
"d": payload,
|
||||
}
|
||||
b, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal presence update: %w", err)
|
||||
}
|
||||
|
||||
resp, _ := r.ws.SendText(ctx, &websocket.SendTextRequest{
|
||||
ConnectionId: username,
|
||||
Message: string(b),
|
||||
})
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("failed to send presence update: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) getDiscordGateway(ctx context.Context) (string, error) {
|
||||
resp, _ := r.web.Get(ctx, &http.HttpRequest{
|
||||
Url: "https://discord.com/api/gateway",
|
||||
})
|
||||
if resp.Error != "" {
|
||||
return "", fmt.Errorf("failed to get Discord gateway: %s", resp.Error)
|
||||
}
|
||||
var result map[string]string
|
||||
err := json.Unmarshal(resp.Body, &result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
|
||||
}
|
||||
return result["url"], nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error {
|
||||
resp, _ := r.mem.GetInt(ctx, &cache.GetRequest{
|
||||
Key: fmt.Sprintf("discord.seq.%s", username),
|
||||
})
|
||||
log.Printf("Sending heartbeat for user %s: %d", username, resp.Value)
|
||||
return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value)
|
||||
}
|
||||
|
||||
func (r *discordRPC) cleanupFailedConnection(ctx context.Context, username string) {
|
||||
log.Printf("Cleaning up failed connection for user %s", username)
|
||||
|
||||
// Cancel the heartbeat schedule
|
||||
if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
|
||||
log.Printf("Failed to cancel heartbeat schedule for user %s: %s", username, resp.Error)
|
||||
}
|
||||
|
||||
// Close the WebSocket connection
|
||||
if resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
|
||||
ConnectionId: username,
|
||||
Code: 1000,
|
||||
Reason: "Connection lost",
|
||||
}); resp.Error != "" {
|
||||
log.Printf("Failed to close WebSocket connection for user %s: %s", username, resp.Error)
|
||||
}
|
||||
|
||||
// Clean up cache entries (just the sequence number, no failure tracking needed)
|
||||
_, _ = r.mem.Remove(ctx, &cache.RemoveRequest{Key: fmt.Sprintf("discord.seq.%s", username)})
|
||||
|
||||
log.Printf("Cleaned up connection for user %s", username)
|
||||
}
|
||||
|
||||
func (r *discordRPC) isConnected(ctx context.Context, username string) bool {
|
||||
// Try to send a heartbeat to test the connection
|
||||
err := r.sendHeartbeat(ctx, username)
|
||||
if err != nil {
|
||||
log.Printf("Heartbeat test failed for user %s: %v", username, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *discordRPC) connect(ctx context.Context, username string, token string) error {
|
||||
if r.isConnected(ctx, username) {
|
||||
log.Printf("Reusing existing connection for user %s", username)
|
||||
return nil
|
||||
}
|
||||
log.Printf("Creating new connection for user %s", username)
|
||||
|
||||
// Get Discord Gateway URL
|
||||
gateway, err := r.getDiscordGateway(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get Discord gateway: %w", err)
|
||||
}
|
||||
log.Printf("Using gateway: %s", gateway)
|
||||
|
||||
// Connect to Discord Gateway
|
||||
resp, _ := r.ws.Connect(ctx, &websocket.ConnectRequest{
|
||||
ConnectionId: username,
|
||||
Url: gateway,
|
||||
})
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("failed to connect to WebSocket: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Send identify payload
|
||||
payload := identifyPayload{
|
||||
Token: token,
|
||||
Intents: 0,
|
||||
Properties: identifyProperties{
|
||||
OS: "Windows 10",
|
||||
Browser: "Discord Client",
|
||||
Device: "Discord Client",
|
||||
},
|
||||
}
|
||||
err = r.sendMessage(ctx, username, gateOpCode, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send identify payload: %w", err)
|
||||
}
|
||||
|
||||
// Schedule heartbeats for this user/connection
|
||||
cronResp, _ := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{
|
||||
CronExpression: fmt.Sprintf("@every %ds", heartbeatInterval),
|
||||
ScheduleId: username,
|
||||
})
|
||||
log.Printf("Scheduled heartbeat for user %s with ID %s", username, cronResp.ScheduleId)
|
||||
|
||||
log.Printf("Successfully authenticated user %s", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) disconnect(ctx context.Context, username string) error {
|
||||
if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" {
|
||||
return fmt.Errorf("failed to cancel schedule: %s", resp.Error)
|
||||
}
|
||||
resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{
|
||||
ConnectionId: username,
|
||||
Code: 1000,
|
||||
Reason: "Navidrome disconnect",
|
||||
})
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("failed to close WebSocket connection: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) {
|
||||
if len(req.Message) < 1024 {
|
||||
log.Printf("Received WebSocket message for connection '%s': %s", req.ConnectionId, req.Message)
|
||||
} else {
|
||||
log.Printf("Received WebSocket message for connection '%s' (truncated): %s...", req.ConnectionId, req.Message[:1021])
|
||||
}
|
||||
|
||||
// Parse the message. If it's a heartbeat_ack, store the sequence number.
|
||||
message := map[string]any{}
|
||||
err := json.Unmarshal([]byte(req.Message), &message)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse WebSocket message: %w", err)
|
||||
}
|
||||
if v := message["s"]; v != nil {
|
||||
seq := int64(v.(float64))
|
||||
log.Printf("Received heartbeat_ack for connection '%s': %d", req.ConnectionId, seq)
|
||||
resp, _ := r.mem.SetInt(ctx, &cache.SetIntRequest{
|
||||
Key: fmt.Sprintf("discord.seq.%s", req.ConnectionId),
|
||||
Value: seq,
|
||||
TtlSeconds: heartbeatInterval * 2,
|
||||
})
|
||||
if !resp.Success {
|
||||
return nil, fmt.Errorf("failed to store sequence number for user %s", req.ConnectionId)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) OnBinaryMessage(_ context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) {
|
||||
log.Printf("Received unexpected binary message for connection '%s'", req.ConnectionId)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) OnError(_ context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) {
|
||||
log.Printf("WebSocket error for connection '%s': %s", req.ConnectionId, req.Error)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) {
|
||||
log.Printf("WebSocket connection '%s' closed with code %d: %s", req.ConnectionId, req.Code, req.Reason)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) {
|
||||
err := r.sendHeartbeat(ctx, req.ScheduleId)
|
||||
if err != nil {
|
||||
// On first heartbeat failure, immediately clean up the connection
|
||||
// The next NowPlaying call will reconnect if needed
|
||||
log.Printf("Heartbeat failed for user %s, cleaning up connection: %v", req.ScheduleId, err)
|
||||
r.cleanupFailedConnection(ctx, req.ScheduleId)
|
||||
return nil, fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
88
plugins/examples/subsonicapi-demo/README.md
Normal file
88
plugins/examples/subsonicapi-demo/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# SubsonicAPI Demo Plugin
|
||||
|
||||
This example plugin demonstrates how to use the SubsonicAPI host service to access Navidrome's Subsonic API from within a plugin.
|
||||
|
||||
## What it does
|
||||
|
||||
The plugin performs the following operations during initialization:
|
||||
|
||||
1. **Ping the server**: Calls `/rest/ping` to check if the Subsonic API is responding
|
||||
2. **Get license info**: Calls `/rest/getLicense` to retrieve server license information
|
||||
|
||||
## Key Features
|
||||
|
||||
- Shows how to request `subsonicapi` permission in the manifest
|
||||
- Demonstrates making Subsonic API calls using the `subsonicapi.Call()` method
|
||||
- Handles both successful responses and errors
|
||||
- Uses proper lifecycle management with `OnInit`
|
||||
|
||||
## Usage
|
||||
|
||||
### Manifest Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"subsonicapi": {
|
||||
"reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins",
|
||||
"allowAdmins": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin Implementation
|
||||
|
||||
```go
|
||||
import "github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
|
||||
var subsonicService = subsonicapi.NewSubsonicAPIService()
|
||||
|
||||
// OnInit is called when the plugin is loaded
|
||||
func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
|
||||
// Make API calls
|
||||
response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
})
|
||||
// Handle response...
|
||||
}
|
||||
```
|
||||
|
||||
When running Navidrome with this plugin installed, it will automatically call the Subsonic API endpoints during the
|
||||
server startup, and you can see the results in the logs:
|
||||
|
||||
```agsl
|
||||
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI Demo Plugin initializing...
|
||||
DEBU[0000] API: New request /ping client=subsonicapi-demo username=admin version=1.16.1
|
||||
DEBU[0000] API: Successful response endpoint=/ping status=OK
|
||||
DEBU[0000] API: New request /getLicense client=subsonicapi-demo username=admin version=1.16.1
|
||||
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI ping response: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true}}
|
||||
DEBU[0000] API: Successful response endpoint=/getLicense status=OK
|
||||
DEBU[0000] Plugin initialized successfully elapsed=41.9ms plugin=subsonicapi-demo
|
||||
INFO[0000] 2022/01/01 00:00:00 SubsonicAPI license info: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true,"license":{"valid":true}}}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Authentication**: The plugin must provide valid authentication parameters in the URL:
|
||||
- **Required**: `u` (username) - The service validates this parameter is present
|
||||
- Example: `"/rest/ping?u=admin"`
|
||||
2. **URL Format**: Only the path and query parameters from the URL are used - host, protocol, and method are ignored
|
||||
3. **Automatic Parameters**: The service automatically adds:
|
||||
- `c`: Plugin name (client identifier)
|
||||
- `v`: Subsonic API version (1.16.1)
|
||||
- `f`: Response format (json)
|
||||
4. **Internal Authentication**: The service sets up internal authentication using the `u` parameter
|
||||
5. **Lifecycle**: This plugin uses `LifecycleManagement` with only the `OnInit` method
|
||||
|
||||
## Building
|
||||
|
||||
This plugin uses the `wasip1` build constraint and must be compiled for WebAssembly:
|
||||
|
||||
```bash
|
||||
# Using the project's make target (recommended)
|
||||
make plugin-examples
|
||||
|
||||
# Manual compilation (when using the proper toolchain)
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
|
||||
```
|
||||
16
plugins/examples/subsonicapi-demo/manifest.json
Normal file
16
plugins/examples/subsonicapi-demo/manifest.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "subsonicapi-demo",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Example plugin demonstrating SubsonicAPI host service usage",
|
||||
"website": "https://github.com/navidrome/navidrome",
|
||||
"capabilities": ["LifecycleManagement"],
|
||||
"permissions": {
|
||||
"subsonicapi": {
|
||||
"reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins",
|
||||
"allowAdmins": true,
|
||||
"allowedUsernames": ["admin"]
|
||||
}
|
||||
}
|
||||
}
|
||||
68
plugins/examples/subsonicapi-demo/plugin.go
Normal file
68
plugins/examples/subsonicapi-demo/plugin.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
)
|
||||
|
||||
// SubsonicAPIService instance for making API calls
|
||||
var subsonicService = subsonicapi.NewSubsonicAPIService()
|
||||
|
||||
// SubsonicAPIDemoPlugin implements LifecycleManagement interface
|
||||
type SubsonicAPIDemoPlugin struct{}
|
||||
|
||||
// OnInit is called when the plugin is loaded
|
||||
func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) {
|
||||
log.Printf("SubsonicAPI Demo Plugin initializing...")
|
||||
|
||||
// Example: Call the ping endpoint to check if the server is alive
|
||||
response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("SubsonicAPI call failed: %v", err)
|
||||
return &api.InitResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if response.Error != "" {
|
||||
log.Printf("SubsonicAPI returned error: %s", response.Error)
|
||||
return &api.InitResponse{Error: response.Error}, nil
|
||||
}
|
||||
|
||||
log.Printf("SubsonicAPI ping response: %s", response.Json)
|
||||
|
||||
// Example: Get server info
|
||||
infoResponse, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{
|
||||
Url: "/rest/getLicense?u=admin",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("SubsonicAPI getLicense call failed: %v", err)
|
||||
return &api.InitResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if infoResponse.Error != "" {
|
||||
log.Printf("SubsonicAPI getLicense returned error: %s", infoResponse.Error)
|
||||
return &api.InitResponse{Error: infoResponse.Error}, nil
|
||||
}
|
||||
|
||||
log.Printf("SubsonicAPI license info: %s", infoResponse.Json)
|
||||
|
||||
return &api.InitResponse{}, nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Subsonic Plugin] ")
|
||||
|
||||
api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{})
|
||||
}
|
||||
32
plugins/examples/wikimedia/README.md
Normal file
32
plugins/examples/wikimedia/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Wikimedia Artist Metadata Plugin
|
||||
|
||||
This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint.
|
||||
|
||||
## Implemented Methods
|
||||
|
||||
- `GetArtistBiography`: Returns the artist's English biography/description from Wikidata.
|
||||
- `GetArtistURL`: Returns the artist's official website (if available) from Wikidata.
|
||||
- `GetArtistImages`: Returns the artist's main image (Wikimedia Commons) from Wikidata.
|
||||
|
||||
All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia.
|
||||
|
||||
## How it Works
|
||||
|
||||
- The plugin uses the host-provided HTTP service (`HttpService`) to make SPARQL queries to the Wikidata endpoint.
|
||||
- No network requests are made directly from the plugin; all HTTP is routed through the host.
|
||||
|
||||
## Building
|
||||
|
||||
To build the plugin to WASM:
|
||||
|
||||
```
|
||||
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory.
|
||||
|
||||
---
|
||||
|
||||
For more details, see the source code in `plugin.go`.
|
||||
20
plugins/examples/wikimedia/manifest.json
Normal file
20
plugins/examples/wikimedia/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json",
|
||||
"name": "wikimedia",
|
||||
"author": "Navidrome",
|
||||
"version": "1.0.0",
|
||||
"description": "Artist information and images from Wikimedia Commons",
|
||||
"website": "https://commons.wikimedia.org",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"permissions": {
|
||||
"http": {
|
||||
"reason": "To fetch artist information and images from Wikimedia Commons API",
|
||||
"allowedUrls": {
|
||||
"https://*.wikimedia.org": ["GET"],
|
||||
"https://*.wikipedia.org": ["GET"],
|
||||
"https://commons.wikimedia.org": ["GET"]
|
||||
},
|
||||
"allowLocalNetwork": false
|
||||
}
|
||||
}
|
||||
}
|
||||
391
plugins/examples/wikimedia/plugin.go
Normal file
391
plugins/examples/wikimedia/plugin.go
Normal file
@@ -0,0 +1,391 @@
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/http"
|
||||
)
|
||||
|
||||
const (
|
||||
wikidataEndpoint = "https://query.wikidata.org/sparql"
|
||||
dbpediaEndpoint = "https://dbpedia.org/sparql"
|
||||
mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
|
||||
requestTimeoutMs = 5000
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = api.ErrNotFound
|
||||
ErrNotImplemented = api.ErrNotImplemented
|
||||
|
||||
client = http.NewHttpService()
|
||||
)
|
||||
|
||||
// SPARQLResult struct for all possible fields
|
||||
// Only the needed field will be non-nil in each context
|
||||
// (Sitelink, Wiki, Comment, Img)
|
||||
type SPARQLResult struct {
|
||||
Results struct {
|
||||
Bindings []struct {
|
||||
Sitelink *struct{ Value string } `json:"sitelink,omitempty"`
|
||||
Wiki *struct{ Value string } `json:"wiki,omitempty"`
|
||||
Comment *struct{ Value string } `json:"comment,omitempty"`
|
||||
Img *struct{ Value string } `json:"img,omitempty"`
|
||||
} `json:"bindings"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// MediaWikiExtractResult is used to unmarshal MediaWiki API extract responses
|
||||
// (for getWikipediaExtract)
|
||||
type MediaWikiExtractResult struct {
|
||||
Query struct {
|
||||
Pages map[string]struct {
|
||||
PageID int `json:"pageid"`
|
||||
Ns int `json:"ns"`
|
||||
Title string `json:"title"`
|
||||
Extract string `json:"extract"`
|
||||
Missing bool `json:"missing"`
|
||||
} `json:"pages"`
|
||||
} `json:"query"`
|
||||
}
|
||||
|
||||
// --- SPARQL Query Helper ---
|
||||
func sparqlQuery(ctx context.Context, client http.HttpService, endpoint, query string) (*SPARQLResult, error) {
|
||||
form := url.Values{}
|
||||
form.Set("query", query)
|
||||
|
||||
req := &http.HttpRequest{
|
||||
Url: endpoint,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/sparql-results+json",
|
||||
"Content-Type": "application/x-www-form-urlencoded", // Required by SPARQL endpoints
|
||||
"User-Agent": "NavidromeWikimediaPlugin/0.1",
|
||||
},
|
||||
Body: []byte(form.Encode()), // Send encoded form data
|
||||
TimeoutMs: requestTimeoutMs,
|
||||
}
|
||||
log.Printf("[Wikimedia Query] Attempting SPARQL query to %s (query length: %d):\n%s", endpoint, len(query), query)
|
||||
resp, err := client.Post(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SPARQL request error: %w", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
log.Printf("[Wikimedia Query] SPARQL HTTP error %d for query to %s. Body: %s", resp.Status, endpoint, string(resp.Body))
|
||||
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status)
|
||||
}
|
||||
var result SPARQLResult
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SPARQL response: %w", err)
|
||||
}
|
||||
if len(result.Results.Bindings) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// --- MediaWiki API Helper ---
|
||||
func mediawikiQuery(ctx context.Context, client http.HttpService, params url.Values) ([]byte, error) {
|
||||
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
|
||||
req := &http.HttpRequest{
|
||||
Url: apiURL,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "NavidromeWikimediaPlugin/0.1",
|
||||
},
|
||||
TimeoutMs: requestTimeoutMs,
|
||||
}
|
||||
resp, err := client.Get(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MediaWiki request error: %w", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
return nil, fmt.Errorf("MediaWiki HTTP error: status %d, body: %s", resp.Status, string(resp.Body))
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// --- Wikidata Fetch Functions ---
|
||||
func getWikidataWikipediaURL(ctx context.Context, client http.HttpService, mbid, name string) (string, error) {
|
||||
var q string
|
||||
if mbid != "" {
|
||||
// Using property chain: ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>.
|
||||
q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist wdt:P434 "%s". ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>. } LIMIT 1`, mbid)
|
||||
} else if name != "" {
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
// Using property chain: ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>.
|
||||
q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist rdfs:label "%s"@en. ?sitelink schema:about ?artist; schema:isPartOf <https://en.wikipedia.org/>. } LIMIT 1`, escapedName)
|
||||
} else {
|
||||
return "", errors.New("MBID or Name required for Wikidata URL lookup")
|
||||
}
|
||||
|
||||
result, err := sparqlQuery(ctx, client, wikidataEndpoint, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Wikidata SPARQL query failed: %w", err)
|
||||
}
|
||||
if result.Results.Bindings[0].Sitelink != nil {
|
||||
return result.Results.Bindings[0].Sitelink.Value, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// --- DBpedia Fetch Functions ---
|
||||
func getDBpediaWikipediaURL(ctx context.Context, client http.HttpService, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
q := fmt.Sprintf(`SELECT ?wiki WHERE { ?artist foaf:name "%s"@en; foaf:isPrimaryTopicOf ?wiki. FILTER regex(str(?wiki), "^https://en.wikipedia.org/") } LIMIT 1`, escapedName)
|
||||
result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DBpedia SPARQL query failed: %w", err)
|
||||
}
|
||||
if result.Results.Bindings[0].Wiki != nil {
|
||||
return result.Results.Bindings[0].Wiki.Value, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
func getDBpediaComment(ctx context.Context, client http.HttpService, name string) (string, error) {
|
||||
if name == "" {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
|
||||
q := fmt.Sprintf(`SELECT ?comment WHERE { ?artist foaf:name "%s"@en; rdfs:comment ?comment. FILTER (lang(?comment) = 'en') } LIMIT 1`, escapedName)
|
||||
result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DBpedia comment SPARQL query failed: %w", err)
|
||||
}
|
||||
if result.Results.Bindings[0].Comment != nil {
|
||||
return result.Results.Bindings[0].Comment.Value, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// --- Wikipedia API Fetch Function ---
|
||||
func getWikipediaExtract(ctx context.Context, client http.HttpService, pageTitle string) (string, error) {
|
||||
if pageTitle == "" {
|
||||
return "", errors.New("page title required for Wikipedia API lookup")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("action", "query")
|
||||
params.Set("format", "json")
|
||||
params.Set("prop", "extracts")
|
||||
params.Set("exintro", "true") // Intro section only
|
||||
params.Set("explaintext", "true") // Plain text
|
||||
params.Set("titles", pageTitle)
|
||||
params.Set("redirects", "1") // Follow redirects
|
||||
|
||||
body, err := mediawikiQuery(ctx, client, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MediaWiki query failed: %w", err)
|
||||
}
|
||||
|
||||
var result MediaWikiExtractResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse MediaWiki response: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through the pages map (usually only one page)
|
||||
for _, page := range result.Query.Pages {
|
||||
if page.Missing {
|
||||
continue // Skip missing pages
|
||||
}
|
||||
if page.Extract != "" {
|
||||
return strings.TrimSpace(page.Extract), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
// --- Helper to get Wikipedia Page Title from URL ---
|
||||
func extractPageTitleFromURL(wikiURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(wikiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsedURL.Host != "en.wikipedia.org" {
|
||||
return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host)
|
||||
}
|
||||
pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/")
|
||||
if len(pathParts) < 2 || pathParts[0] != "wiki" {
|
||||
return "", fmt.Errorf("URL path does not match /wiki/<title> format: %s", parsedURL.Path)
|
||||
}
|
||||
title := pathParts[1]
|
||||
if title == "" {
|
||||
return "", errors.New("extracted title is empty")
|
||||
}
|
||||
decodedTitle, err := url.PathUnescape(title)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
|
||||
}
|
||||
return decodedTitle, nil
|
||||
}
|
||||
|
||||
// --- Agent Implementation ---
|
||||
type WikimediaAgent struct{}
|
||||
|
||||
// GetArtistURL fetches the Wikipedia URL.
|
||||
// Order: Wikidata(MBID/Name) -> DBpedia(Name) -> Search URL
|
||||
func (WikimediaAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) {
|
||||
var wikiURL string
|
||||
var err error
|
||||
|
||||
// 1. Try Wikidata (MBID first, then name)
|
||||
wikiURL, err = getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name)
|
||||
if err == nil && wikiURL != "" {
|
||||
return &api.ArtistURLResponse{Url: wikiURL}, nil
|
||||
}
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia] Error fetching Wikidata URL: %v\n", err)
|
||||
// Don't stop, try DBpedia
|
||||
}
|
||||
|
||||
// 2. Try DBpedia (Name only)
|
||||
if req.Name != "" {
|
||||
wikiURL, err = getDBpediaWikipediaURL(ctx, client, req.Name)
|
||||
if err == nil && wikiURL != "" {
|
||||
return &api.ArtistURLResponse{Url: wikiURL}, nil
|
||||
}
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia] Error fetching DBpedia URL: %v\n", err)
|
||||
// Don't stop, generate search URL
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to search URL
|
||||
if req.Name != "" {
|
||||
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(req.Name))
|
||||
log.Printf("[Wikimedia] URL not found, falling back to search URL: %s\n", searchURL)
|
||||
return &api.ArtistURLResponse{Url: searchURL}, nil
|
||||
}
|
||||
|
||||
log.Printf("[Wikimedia] Could not determine Wikipedia URL for: %s (%s)\n", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistBiography fetches the long biography.
|
||||
// Order: Wikipedia API (via Wikidata/DBpedia URL) -> DBpedia Comment (Name)
|
||||
func (WikimediaAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) {
|
||||
var bio string
|
||||
var err error
|
||||
|
||||
log.Printf("[Wikimedia Bio] Fetching for Name: %s, MBID: %s", req.Name, req.Mbid)
|
||||
|
||||
// 1. Get Wikipedia URL (using the logic from GetArtistURL)
|
||||
wikiURL := ""
|
||||
// Try Wikidata first
|
||||
tempURL, wdErr := getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name)
|
||||
if wdErr == nil && tempURL != "" {
|
||||
log.Printf("[Wikimedia Bio] Found Wikidata URL: %s", tempURL)
|
||||
wikiURL = tempURL
|
||||
} else if req.Name != "" {
|
||||
// Try DBpedia if Wikidata failed or returned not found
|
||||
log.Printf("[Wikimedia Bio] Wikidata URL failed (%v), trying DBpedia URL", wdErr)
|
||||
tempURL, dbErr := getDBpediaWikipediaURL(ctx, client, req.Name)
|
||||
if dbErr == nil && tempURL != "" {
|
||||
log.Printf("[Wikimedia Bio] Found DBpedia URL: %s", tempURL)
|
||||
wikiURL = tempURL
|
||||
} else {
|
||||
log.Printf("[Wikimedia Bio] DBpedia URL failed (%v)", dbErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If Wikipedia URL found, try MediaWiki API
|
||||
if wikiURL != "" {
|
||||
pageTitle, err := extractPageTitleFromURL(wikiURL)
|
||||
if err == nil {
|
||||
log.Printf("[Wikimedia Bio] Extracted page title: %s", pageTitle)
|
||||
bio, err = getWikipediaExtract(ctx, client, pageTitle)
|
||||
if err == nil && bio != "" {
|
||||
log.Printf("[Wikimedia Bio] Found Wikipedia extract.")
|
||||
return &api.ArtistBiographyResponse{Biography: bio}, nil
|
||||
}
|
||||
log.Printf("[Wikimedia Bio] Wikipedia extract failed: %v", err)
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia Bio] Error fetching Wikipedia extract for '%s': %v", pageTitle, err)
|
||||
// Don't stop, try DBpedia comment
|
||||
}
|
||||
} else {
|
||||
log.Printf("[Wikimedia Bio] Error extracting page title from URL '%s': %v", wikiURL, err)
|
||||
// Don't stop, try DBpedia comment
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to DBpedia Comment (Name only)
|
||||
if req.Name != "" {
|
||||
log.Printf("[Wikimedia Bio] Falling back to DBpedia comment for name: %s", req.Name)
|
||||
bio, err = getDBpediaComment(ctx, client, req.Name)
|
||||
if err == nil && bio != "" {
|
||||
log.Printf("[Wikimedia Bio] Found DBpedia comment.")
|
||||
return &api.ArtistBiographyResponse{Biography: bio}, nil
|
||||
}
|
||||
log.Printf("[Wikimedia Bio] DBpedia comment failed: %v", err)
|
||||
if err != nil && err != ErrNotFound {
|
||||
log.Printf("[Wikimedia Bio] Error fetching DBpedia comment for '%s': %v", req.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[Wikimedia Bio] Final: Biography not found for: %s (%s)", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// GetArtistImages fetches images (Wikidata only for now)
|
||||
func (WikimediaAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) {
|
||||
var q string
|
||||
if req.Mbid != "" {
|
||||
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, req.Mbid)
|
||||
} else if req.Name != "" {
|
||||
escapedName := strings.ReplaceAll(req.Name, "\"", "\\\"")
|
||||
q = fmt.Sprintf(`SELECT ?img WHERE { ?artist rdfs:label "%s"@en; wdt:P18 ?img } LIMIT 1`, escapedName)
|
||||
} else {
|
||||
return nil, errors.New("MBID or Name required for Wikidata Image lookup")
|
||||
}
|
||||
|
||||
result, err := sparqlQuery(ctx, client, wikidataEndpoint, q)
|
||||
if err != nil {
|
||||
log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if result.Results.Bindings[0].Img != nil {
|
||||
return &api.ArtistImageResponse{Images: []*api.ExternalImage{{Url: result.Results.Bindings[0].Img.Value, Size: 0}}}, nil
|
||||
}
|
||||
log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid)
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// Not implemented methods
|
||||
func (WikimediaAgent) GetArtistMBID(context.Context, *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (WikimediaAgent) GetSimilarArtists(context.Context, *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (WikimediaAgent) GetArtistTopSongs(context.Context, *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (WikimediaAgent) GetAlbumInfo(context.Context, *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
func init() {
|
||||
// Configure logging: No timestamps, no source file/line
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("[Wikimedia] ")
|
||||
|
||||
api.RegisterMetadataAgent(WikimediaAgent{})
|
||||
}
|
||||
73
plugins/host/artwork/artwork.pb.go
Normal file
73
plugins/host/artwork/artwork.pb.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/artwork/artwork.proto
|
||||
|
||||
package artwork
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type GetArtworkUrlRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Optional, 0 means original size
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlRequest) GetId() string {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlRequest) GetSize() int32 {
|
||||
if x != nil {
|
||||
return x.Size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type GetArtworkUrlResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetArtworkUrlResponse) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type ArtworkService interface {
|
||||
GetArtistUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
|
||||
GetAlbumUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
|
||||
GetTrackUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error)
|
||||
}
|
||||
21
plugins/host/artwork/artwork.proto
Normal file
21
plugins/host/artwork/artwork.proto
Normal file
@@ -0,0 +1,21 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package artwork;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/artwork;artwork";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service ArtworkService {
|
||||
rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
|
||||
rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
|
||||
rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse);
|
||||
}
|
||||
|
||||
message GetArtworkUrlRequest {
|
||||
string id = 1;
|
||||
int32 size = 2; // Optional, 0 means original size
|
||||
}
|
||||
|
||||
message GetArtworkUrlResponse {
|
||||
string url = 1;
|
||||
}
|
||||
130
plugins/host/artwork/artwork_host.pb.go
Normal file
130
plugins/host/artwork/artwork_host.pb.go
Normal file
@@ -0,0 +1,130 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/artwork/artwork.proto
|
||||
|
||||
package artwork
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _artworkService struct {
|
||||
ArtworkService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ArtworkService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _artworkService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetArtistUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_artist_url")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetAlbumUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_album_url")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetTrackUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_track_url")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _artworkService) _GetArtistUrl(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetArtworkUrlRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetArtistUrl(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _artworkService) _GetAlbumUrl(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetArtworkUrlRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetAlbumUrl(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _artworkService) _GetTrackUrl(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetArtworkUrlRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetTrackUrl(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
90
plugins/host/artwork/artwork_plugin.pb.go
Normal file
90
plugins/host/artwork/artwork_plugin.pb.go
Normal file
@@ -0,0 +1,90 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/artwork/artwork.proto
|
||||
|
||||
package artwork
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type artworkService struct{}
|
||||
|
||||
func NewArtworkService() ArtworkService {
|
||||
return artworkService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env get_artist_url
|
||||
func _get_artist_url(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h artworkService) GetArtistUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_artist_url(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetArtworkUrlResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_album_url
|
||||
func _get_album_url(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h artworkService) GetAlbumUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_album_url(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetArtworkUrlResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_track_url
|
||||
func _get_track_url(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h artworkService) GetTrackUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_track_url(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetArtworkUrlResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/artwork/artwork_plugin_dev.go
Normal file
7
plugins/host/artwork/artwork_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package artwork
|
||||
|
||||
func NewArtworkService() ArtworkService {
|
||||
panic("not implemented")
|
||||
}
|
||||
425
plugins/host/artwork/artwork_vtproto.pb.go
Normal file
425
plugins/host/artwork/artwork_vtproto.pb.go
Normal file
@@ -0,0 +1,425 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/artwork/artwork.proto
|
||||
|
||||
package artwork
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *GetArtworkUrlRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *GetArtworkUrlRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *GetArtworkUrlRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if m.Size != 0 {
|
||||
i = encodeVarint(dAtA, i, uint64(m.Size))
|
||||
i--
|
||||
dAtA[i] = 0x10
|
||||
}
|
||||
if len(m.Id) > 0 {
|
||||
i -= len(m.Id)
|
||||
copy(dAtA[i:], m.Id)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Id)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *GetArtworkUrlResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *GetArtworkUrlResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *GetArtworkUrlResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Url) > 0 {
|
||||
i -= len(m.Url)
|
||||
copy(dAtA[i:], m.Url)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *GetArtworkUrlRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Id)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
if m.Size != 0 {
|
||||
n += 1 + sov(uint64(m.Size))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *GetArtworkUrlResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Url)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *GetArtworkUrlRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: GetArtworkUrlRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: GetArtworkUrlRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Id = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
case 2:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType)
|
||||
}
|
||||
m.Size = 0
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
m.Size |= int32(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *GetArtworkUrlResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: GetArtworkUrlResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: GetArtworkUrlResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Url = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
420
plugins/host/cache/cache.pb.go
vendored
Normal file
420
plugins/host/cache/cache.pb.go
vendored
Normal file
@@ -0,0 +1,420 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/cache/cache.proto
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
// Request to store a string value
|
||||
type SetStringRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // String value to store
|
||||
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
func (x *SetStringRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SetStringRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetStringRequest) GetValue() string {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetStringRequest) GetTtlSeconds() int64 {
|
||||
if x != nil {
|
||||
return x.TtlSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Request to store an integer value
|
||||
type SetIntRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // Integer value to store
|
||||
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
func (x *SetIntRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SetIntRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetIntRequest) GetValue() int64 {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SetIntRequest) GetTtlSeconds() int64 {
|
||||
if x != nil {
|
||||
return x.TtlSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Request to store a float value
|
||||
type SetFloatRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Float value to store
|
||||
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
func (x *SetFloatRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SetFloatRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetFloatRequest) GetValue() float64 {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SetFloatRequest) GetTtlSeconds() int64 {
|
||||
if x != nil {
|
||||
return x.TtlSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Request to store a byte slice value
|
||||
type SetBytesRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // Byte slice value to store
|
||||
TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
func (x *SetBytesRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SetBytesRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SetBytesRequest) GetValue() []byte {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SetBytesRequest) GetTtlSeconds() int64 {
|
||||
if x != nil {
|
||||
return x.TtlSeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Response after setting a value
|
||||
type SetResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful
|
||||
}
|
||||
|
||||
func (x *SetResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SetResponse) GetSuccess() bool {
|
||||
if x != nil {
|
||||
return x.Success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Request to get a value
|
||||
type GetRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
}
|
||||
|
||||
func (x *GetRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Response containing a string value
|
||||
type GetStringResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
|
||||
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The string value (if exists is true)
|
||||
}
|
||||
|
||||
func (x *GetStringResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetStringResponse) GetExists() bool {
|
||||
if x != nil {
|
||||
return x.Exists
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *GetStringResponse) GetValue() string {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Response containing an integer value
|
||||
type GetIntResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
|
||||
Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // The integer value (if exists is true)
|
||||
}
|
||||
|
||||
func (x *GetIntResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetIntResponse) GetExists() bool {
|
||||
if x != nil {
|
||||
return x.Exists
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *GetIntResponse) GetValue() int64 {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Response containing a float value
|
||||
type GetFloatResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
|
||||
Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // The float value (if exists is true)
|
||||
}
|
||||
|
||||
func (x *GetFloatResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetFloatResponse) GetExists() bool {
|
||||
if x != nil {
|
||||
return x.Exists
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *GetFloatResponse) GetValue() float64 {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Response containing a byte slice value
|
||||
type GetBytesResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
|
||||
Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The byte slice value (if exists is true)
|
||||
}
|
||||
|
||||
func (x *GetBytesResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetBytesResponse) GetExists() bool {
|
||||
if x != nil {
|
||||
return x.Exists
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *GetBytesResponse) GetValue() []byte {
|
||||
if x != nil {
|
||||
return x.Value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Request to remove a value
|
||||
type RemoveRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
}
|
||||
|
||||
func (x *RemoveRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *RemoveRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Response after removing a value
|
||||
type RemoveResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful
|
||||
}
|
||||
|
||||
func (x *RemoveResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *RemoveResponse) GetSuccess() bool {
|
||||
if x != nil {
|
||||
return x.Success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Request to check if a key exists
|
||||
type HasRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key
|
||||
}
|
||||
|
||||
func (x *HasRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *HasRequest) GetKey() string {
|
||||
if x != nil {
|
||||
return x.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Response indicating if a key exists
|
||||
type HasResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists
|
||||
}
|
||||
|
||||
func (x *HasResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *HasResponse) GetExists() bool {
|
||||
if x != nil {
|
||||
return x.Exists
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type CacheService interface {
|
||||
// Set a string value in the cache
|
||||
SetString(context.Context, *SetStringRequest) (*SetResponse, error)
|
||||
// Get a string value from the cache
|
||||
GetString(context.Context, *GetRequest) (*GetStringResponse, error)
|
||||
// Set an integer value in the cache
|
||||
SetInt(context.Context, *SetIntRequest) (*SetResponse, error)
|
||||
// Get an integer value from the cache
|
||||
GetInt(context.Context, *GetRequest) (*GetIntResponse, error)
|
||||
// Set a float value in the cache
|
||||
SetFloat(context.Context, *SetFloatRequest) (*SetResponse, error)
|
||||
// Get a float value from the cache
|
||||
GetFloat(context.Context, *GetRequest) (*GetFloatResponse, error)
|
||||
// Set a byte slice value in the cache
|
||||
SetBytes(context.Context, *SetBytesRequest) (*SetResponse, error)
|
||||
// Get a byte slice value from the cache
|
||||
GetBytes(context.Context, *GetRequest) (*GetBytesResponse, error)
|
||||
// Remove a value from the cache
|
||||
Remove(context.Context, *RemoveRequest) (*RemoveResponse, error)
|
||||
// Check if a key exists in the cache
|
||||
Has(context.Context, *HasRequest) (*HasResponse, error)
|
||||
}
|
||||
120
plugins/host/cache/cache.proto
vendored
Normal file
120
plugins/host/cache/cache.proto
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cache;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/cache;cache";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service CacheService {
|
||||
// Set a string value in the cache
|
||||
rpc SetString(SetStringRequest) returns (SetResponse);
|
||||
|
||||
// Get a string value from the cache
|
||||
rpc GetString(GetRequest) returns (GetStringResponse);
|
||||
|
||||
// Set an integer value in the cache
|
||||
rpc SetInt(SetIntRequest) returns (SetResponse);
|
||||
|
||||
// Get an integer value from the cache
|
||||
rpc GetInt(GetRequest) returns (GetIntResponse);
|
||||
|
||||
// Set a float value in the cache
|
||||
rpc SetFloat(SetFloatRequest) returns (SetResponse);
|
||||
|
||||
// Get a float value from the cache
|
||||
rpc GetFloat(GetRequest) returns (GetFloatResponse);
|
||||
|
||||
// Set a byte slice value in the cache
|
||||
rpc SetBytes(SetBytesRequest) returns (SetResponse);
|
||||
|
||||
// Get a byte slice value from the cache
|
||||
rpc GetBytes(GetRequest) returns (GetBytesResponse);
|
||||
|
||||
// Remove a value from the cache
|
||||
rpc Remove(RemoveRequest) returns (RemoveResponse);
|
||||
|
||||
// Check if a key exists in the cache
|
||||
rpc Has(HasRequest) returns (HasResponse);
|
||||
}
|
||||
|
||||
// Request to store a string value
|
||||
message SetStringRequest {
|
||||
string key = 1; // Cache key
|
||||
string value = 2; // String value to store
|
||||
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
// Request to store an integer value
|
||||
message SetIntRequest {
|
||||
string key = 1; // Cache key
|
||||
int64 value = 2; // Integer value to store
|
||||
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
// Request to store a float value
|
||||
message SetFloatRequest {
|
||||
string key = 1; // Cache key
|
||||
double value = 2; // Float value to store
|
||||
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
// Request to store a byte slice value
|
||||
message SetBytesRequest {
|
||||
string key = 1; // Cache key
|
||||
bytes value = 2; // Byte slice value to store
|
||||
int64 ttl_seconds = 3; // TTL in seconds, 0 means use default
|
||||
}
|
||||
|
||||
// Response after setting a value
|
||||
message SetResponse {
|
||||
bool success = 1; // Whether the operation was successful
|
||||
}
|
||||
|
||||
// Request to get a value
|
||||
message GetRequest {
|
||||
string key = 1; // Cache key
|
||||
}
|
||||
|
||||
// Response containing a string value
|
||||
message GetStringResponse {
|
||||
bool exists = 1; // Whether the key exists
|
||||
string value = 2; // The string value (if exists is true)
|
||||
}
|
||||
|
||||
// Response containing an integer value
|
||||
message GetIntResponse {
|
||||
bool exists = 1; // Whether the key exists
|
||||
int64 value = 2; // The integer value (if exists is true)
|
||||
}
|
||||
|
||||
// Response containing a float value
|
||||
message GetFloatResponse {
|
||||
bool exists = 1; // Whether the key exists
|
||||
double value = 2; // The float value (if exists is true)
|
||||
}
|
||||
|
||||
// Response containing a byte slice value
|
||||
message GetBytesResponse {
|
||||
bool exists = 1; // Whether the key exists
|
||||
bytes value = 2; // The byte slice value (if exists is true)
|
||||
}
|
||||
|
||||
// Request to remove a value
|
||||
message RemoveRequest {
|
||||
string key = 1; // Cache key
|
||||
}
|
||||
|
||||
// Response after removing a value
|
||||
message RemoveResponse {
|
||||
bool success = 1; // Whether the operation was successful
|
||||
}
|
||||
|
||||
// Request to check if a key exists
|
||||
message HasRequest {
|
||||
string key = 1; // Cache key
|
||||
}
|
||||
|
||||
// Response indicating if a key exists
|
||||
message HasResponse {
|
||||
bool exists = 1; // Whether the key exists
|
||||
}
|
||||
374
plugins/host/cache/cache_host.pb.go
vendored
Normal file
374
plugins/host/cache/cache_host.pb.go
vendored
Normal file
@@ -0,0 +1,374 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/cache/cache.proto
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _cacheService struct {
|
||||
CacheService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions CacheService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _cacheService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._SetString), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("set_string")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetString), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_string")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._SetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("set_int")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_int")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._SetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("set_float")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_float")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._SetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("set_bytes")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_bytes")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Remove), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("remove")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Has), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("has")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set a string value in the cache
|
||||
|
||||
func (h _cacheService) _SetString(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(SetStringRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.SetString(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Get a string value from the cache
|
||||
|
||||
func (h _cacheService) _GetString(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetString(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Set an integer value in the cache
|
||||
|
||||
func (h _cacheService) _SetInt(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(SetIntRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.SetInt(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Get an integer value from the cache
|
||||
|
||||
func (h _cacheService) _GetInt(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetInt(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Set a float value in the cache
|
||||
|
||||
func (h _cacheService) _SetFloat(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(SetFloatRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.SetFloat(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Get a float value from the cache
|
||||
|
||||
func (h _cacheService) _GetFloat(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetFloat(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Set a byte slice value in the cache
|
||||
|
||||
func (h _cacheService) _SetBytes(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(SetBytesRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.SetBytes(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Get a byte slice value from the cache
|
||||
|
||||
func (h _cacheService) _GetBytes(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetBytes(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Remove a value from the cache
|
||||
|
||||
func (h _cacheService) _Remove(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(RemoveRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Remove(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Check if a key exists in the cache
|
||||
|
||||
func (h _cacheService) _Has(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HasRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Has(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
251
plugins/host/cache/cache_plugin.pb.go
vendored
Normal file
251
plugins/host/cache/cache_plugin.pb.go
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/cache/cache.proto
|
||||
|
||||
package cache
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type cacheService struct{}
|
||||
|
||||
func NewCacheService() CacheService {
|
||||
return cacheService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env set_string
|
||||
func _set_string(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetString(ctx context.Context, request *SetStringRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_string(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_string
|
||||
func _get_string(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetString(ctx context.Context, request *GetRequest) (*GetStringResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_string(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetStringResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env set_int
|
||||
func _set_int(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetInt(ctx context.Context, request *SetIntRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_int(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_int
|
||||
func _get_int(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetInt(ctx context.Context, request *GetRequest) (*GetIntResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_int(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetIntResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env set_float
|
||||
func _set_float(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetFloat(ctx context.Context, request *SetFloatRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_float(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_float
|
||||
func _get_float(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetFloat(ctx context.Context, request *GetRequest) (*GetFloatResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_float(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetFloatResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env set_bytes
|
||||
func _set_bytes(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) SetBytes(ctx context.Context, request *SetBytesRequest) (*SetResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _set_bytes(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SetResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env get_bytes
|
||||
func _get_bytes(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) GetBytes(ctx context.Context, request *GetRequest) (*GetBytesResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_bytes(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetBytesResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env remove
|
||||
func _remove(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) Remove(ctx context.Context, request *RemoveRequest) (*RemoveResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _remove(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(RemoveResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env has
|
||||
func _has(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h cacheService) Has(ctx context.Context, request *HasRequest) (*HasResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _has(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HasResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/cache/cache_plugin_dev.go
vendored
Normal file
7
plugins/host/cache/cache_plugin_dev.go
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package cache
|
||||
|
||||
func NewCacheService() CacheService {
|
||||
panic("not implemented")
|
||||
}
|
||||
2352
plugins/host/cache/cache_vtproto.pb.go
vendored
Normal file
2352
plugins/host/cache/cache_vtproto.pb.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
54
plugins/host/config/config.pb.go
Normal file
54
plugins/host/config/config.pb.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type GetPluginConfigRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *GetPluginConfigRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
type GetPluginConfigResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
}
|
||||
|
||||
func (x *GetPluginConfigResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *GetPluginConfigResponse) GetConfig() map[string]string {
|
||||
if x != nil {
|
||||
return x.Config
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type ConfigService interface {
|
||||
GetPluginConfig(context.Context, *GetPluginConfigRequest) (*GetPluginConfigResponse, error)
|
||||
}
|
||||
18
plugins/host/config/config.proto
Normal file
18
plugins/host/config/config.proto
Normal file
@@ -0,0 +1,18 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package config;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/config;config";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service ConfigService {
|
||||
rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse);
|
||||
}
|
||||
|
||||
message GetPluginConfigRequest {
|
||||
// No fields needed; plugin name is inferred from context
|
||||
}
|
||||
|
||||
message GetPluginConfigResponse {
|
||||
map<string, string> config = 1;
|
||||
}
|
||||
66
plugins/host/config/config_host.pb.go
Normal file
66
plugins/host/config/config_host.pb.go
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _configService struct {
|
||||
ConfigService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ConfigService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _configService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._GetPluginConfig), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get_plugin_config")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _configService) _GetPluginConfig(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(GetPluginConfigRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.GetPluginConfig(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
44
plugins/host/config/config_plugin.pb.go
Normal file
44
plugins/host/config/config_plugin.pb.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type configService struct{}
|
||||
|
||||
func NewConfigService() ConfigService {
|
||||
return configService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env get_plugin_config
|
||||
func _get_plugin_config(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h configService) GetPluginConfig(ctx context.Context, request *GetPluginConfigRequest) (*GetPluginConfigResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get_plugin_config(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(GetPluginConfigResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/config/config_plugin_dev.go
Normal file
7
plugins/host/config/config_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package config
|
||||
|
||||
func NewConfigService() ConfigService {
|
||||
panic("not implemented")
|
||||
}
|
||||
466
plugins/host/config/config_vtproto.pb.go
Normal file
466
plugins/host/config/config_vtproto.pb.go
Normal file
@@ -0,0 +1,466 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/config/config.proto
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *GetPluginConfigRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Config) > 0 {
|
||||
for k := range m.Config {
|
||||
v := m.Config[k]
|
||||
baseI := i
|
||||
i -= len(v)
|
||||
copy(dAtA[i:], v)
|
||||
i = encodeVarint(dAtA, i, uint64(len(v)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
i -= len(k)
|
||||
copy(dAtA[i:], k)
|
||||
i = encodeVarint(dAtA, i, uint64(len(k)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
i = encodeVarint(dAtA, i, uint64(baseI-i))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *GetPluginConfigRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *GetPluginConfigResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
if len(m.Config) > 0 {
|
||||
for k, v := range m.Config {
|
||||
_ = k
|
||||
_ = v
|
||||
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
|
||||
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
|
||||
}
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *GetPluginConfigRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: GetPluginConfigRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: GetPluginConfigRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *GetPluginConfigResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: GetPluginConfigResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: GetPluginConfigResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType)
|
||||
}
|
||||
var msglen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
msglen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if msglen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + msglen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if m.Config == nil {
|
||||
m.Config = make(map[string]string)
|
||||
}
|
||||
var mapkey string
|
||||
var mapvalue string
|
||||
for iNdEx < postIndex {
|
||||
entryPreIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
if fieldNum == 1 {
|
||||
var stringLenmapkey uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapkey |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapkey := int(stringLenmapkey)
|
||||
if intStringLenmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapkey := iNdEx + intStringLenmapkey
|
||||
if postStringIndexmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapkey > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
|
||||
iNdEx = postStringIndexmapkey
|
||||
} else if fieldNum == 2 {
|
||||
var stringLenmapvalue uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapvalue |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapvalue := int(stringLenmapvalue)
|
||||
if intStringLenmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
|
||||
if postStringIndexmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapvalue > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
|
||||
iNdEx = postStringIndexmapvalue
|
||||
} else {
|
||||
iNdEx = entryPreIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > postIndex {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
m.Config[mapkey] = mapvalue
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
117
plugins/host/http/http.pb.go
Normal file
117
plugins/host/http/http.pb.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type HttpRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
TimeoutMs int32 `protobuf:"varint,3,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"`
|
||||
Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` // Ignored for GET/DELETE/HEAD/OPTIONS
|
||||
}
|
||||
|
||||
func (x *HttpRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetTimeoutMs() int32 {
|
||||
if x != nil {
|
||||
return x.TimeoutMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *HttpRequest) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type HttpResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if network/protocol error
|
||||
}
|
||||
|
||||
func (x *HttpResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetStatus() int32 {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetBody() []byte {
|
||||
if x != nil {
|
||||
return x.Body
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *HttpResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type HttpService interface {
|
||||
Get(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Post(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Put(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Delete(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Patch(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Head(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
Options(context.Context, *HttpRequest) (*HttpResponse, error)
|
||||
}
|
||||
30
plugins/host/http/http.proto
Normal file
30
plugins/host/http/http.proto
Normal file
@@ -0,0 +1,30 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package http;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/http;http";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service HttpService {
|
||||
rpc Get(HttpRequest) returns (HttpResponse);
|
||||
rpc Post(HttpRequest) returns (HttpResponse);
|
||||
rpc Put(HttpRequest) returns (HttpResponse);
|
||||
rpc Delete(HttpRequest) returns (HttpResponse);
|
||||
rpc Patch(HttpRequest) returns (HttpResponse);
|
||||
rpc Head(HttpRequest) returns (HttpResponse);
|
||||
rpc Options(HttpRequest) returns (HttpResponse);
|
||||
}
|
||||
|
||||
message HttpRequest {
|
||||
string url = 1;
|
||||
map<string, string> headers = 2;
|
||||
int32 timeout_ms = 3;
|
||||
bytes body = 4; // Ignored for GET/DELETE/HEAD/OPTIONS
|
||||
}
|
||||
|
||||
message HttpResponse {
|
||||
int32 status = 1;
|
||||
bytes body = 2;
|
||||
map<string, string> headers = 3;
|
||||
string error = 4; // Non-empty if network/protocol error
|
||||
}
|
||||
258
plugins/host/http/http_host.pb.go
Normal file
258
plugins/host/http/http_host.pb.go
Normal file
@@ -0,0 +1,258 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _httpService struct {
|
||||
HttpService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions HttpService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _httpService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Get), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("get")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Post), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("post")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Put), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("put")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Delete), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("delete")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Patch), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("patch")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Head), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("head")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Options), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("options")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _httpService) _Get(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Get(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Post(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Post(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Put(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Put(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Delete(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Delete(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Patch(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Patch(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Head(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Head(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
func (h _httpService) _Options(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(HttpRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Options(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
182
plugins/host/http/http_plugin.pb.go
Normal file
182
plugins/host/http/http_plugin.pb.go
Normal file
@@ -0,0 +1,182 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type httpService struct{}
|
||||
|
||||
func NewHttpService() HttpService {
|
||||
return httpService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env get
|
||||
func _get(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Get(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _get(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env post
|
||||
func _post(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Post(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _post(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env put
|
||||
func _put(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Put(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _put(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env delete
|
||||
func _delete(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Delete(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _delete(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env patch
|
||||
func _patch(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Patch(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _patch(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env head
|
||||
func _head(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Head(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _head(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env options
|
||||
func _options(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h httpService) Options(ctx context.Context, request *HttpRequest) (*HttpResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _options(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(HttpResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/http/http_plugin_dev.go
Normal file
7
plugins/host/http/http_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package http
|
||||
|
||||
func NewHttpService() HttpService {
|
||||
panic("not implemented")
|
||||
}
|
||||
850
plugins/host/http/http_vtproto.pb.go
Normal file
850
plugins/host/http/http_vtproto.pb.go
Normal file
@@ -0,0 +1,850 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/http/http.proto
|
||||
|
||||
package http
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *HttpRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *HttpRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *HttpRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Body) > 0 {
|
||||
i -= len(m.Body)
|
||||
copy(dAtA[i:], m.Body)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Body)))
|
||||
i--
|
||||
dAtA[i] = 0x22
|
||||
}
|
||||
if m.TimeoutMs != 0 {
|
||||
i = encodeVarint(dAtA, i, uint64(m.TimeoutMs))
|
||||
i--
|
||||
dAtA[i] = 0x18
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k := range m.Headers {
|
||||
v := m.Headers[k]
|
||||
baseI := i
|
||||
i -= len(v)
|
||||
copy(dAtA[i:], v)
|
||||
i = encodeVarint(dAtA, i, uint64(len(v)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
i -= len(k)
|
||||
copy(dAtA[i:], k)
|
||||
i = encodeVarint(dAtA, i, uint64(len(k)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
i = encodeVarint(dAtA, i, uint64(baseI-i))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
}
|
||||
}
|
||||
if len(m.Url) > 0 {
|
||||
i -= len(m.Url)
|
||||
copy(dAtA[i:], m.Url)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *HttpResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *HttpResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *HttpResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Error) > 0 {
|
||||
i -= len(m.Error)
|
||||
copy(dAtA[i:], m.Error)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Error)))
|
||||
i--
|
||||
dAtA[i] = 0x22
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k := range m.Headers {
|
||||
v := m.Headers[k]
|
||||
baseI := i
|
||||
i -= len(v)
|
||||
copy(dAtA[i:], v)
|
||||
i = encodeVarint(dAtA, i, uint64(len(v)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
i -= len(k)
|
||||
copy(dAtA[i:], k)
|
||||
i = encodeVarint(dAtA, i, uint64(len(k)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
i = encodeVarint(dAtA, i, uint64(baseI-i))
|
||||
i--
|
||||
dAtA[i] = 0x1a
|
||||
}
|
||||
}
|
||||
if len(m.Body) > 0 {
|
||||
i -= len(m.Body)
|
||||
copy(dAtA[i:], m.Body)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Body)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
}
|
||||
if m.Status != 0 {
|
||||
i = encodeVarint(dAtA, i, uint64(m.Status))
|
||||
i--
|
||||
dAtA[i] = 0x8
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *HttpRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Url)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k, v := range m.Headers {
|
||||
_ = k
|
||||
_ = v
|
||||
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
|
||||
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
|
||||
}
|
||||
}
|
||||
if m.TimeoutMs != 0 {
|
||||
n += 1 + sov(uint64(m.TimeoutMs))
|
||||
}
|
||||
l = len(m.Body)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *HttpResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
if m.Status != 0 {
|
||||
n += 1 + sov(uint64(m.Status))
|
||||
}
|
||||
l = len(m.Body)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
if len(m.Headers) > 0 {
|
||||
for k, v := range m.Headers {
|
||||
_ = k
|
||||
_ = v
|
||||
mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v)))
|
||||
n += mapEntrySize + 1 + sov(uint64(mapEntrySize))
|
||||
}
|
||||
}
|
||||
l = len(m.Error)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *HttpRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: HttpRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: HttpRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Url = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
case 2:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType)
|
||||
}
|
||||
var msglen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
msglen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if msglen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + msglen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if m.Headers == nil {
|
||||
m.Headers = make(map[string]string)
|
||||
}
|
||||
var mapkey string
|
||||
var mapvalue string
|
||||
for iNdEx < postIndex {
|
||||
entryPreIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
if fieldNum == 1 {
|
||||
var stringLenmapkey uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapkey |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapkey := int(stringLenmapkey)
|
||||
if intStringLenmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapkey := iNdEx + intStringLenmapkey
|
||||
if postStringIndexmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapkey > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
|
||||
iNdEx = postStringIndexmapkey
|
||||
} else if fieldNum == 2 {
|
||||
var stringLenmapvalue uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapvalue |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapvalue := int(stringLenmapvalue)
|
||||
if intStringLenmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
|
||||
if postStringIndexmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapvalue > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
|
||||
iNdEx = postStringIndexmapvalue
|
||||
} else {
|
||||
iNdEx = entryPreIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > postIndex {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
m.Headers[mapkey] = mapvalue
|
||||
iNdEx = postIndex
|
||||
case 3:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field TimeoutMs", wireType)
|
||||
}
|
||||
m.TimeoutMs = 0
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
m.TimeoutMs |= int32(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType)
|
||||
}
|
||||
var byteLen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
byteLen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if byteLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + byteLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...)
|
||||
if m.Body == nil {
|
||||
m.Body = []byte{}
|
||||
}
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *HttpResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: HttpResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: HttpResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 0 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Status", wireType)
|
||||
}
|
||||
m.Status = 0
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
m.Status |= int32(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType)
|
||||
}
|
||||
var byteLen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
byteLen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if byteLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + byteLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...)
|
||||
if m.Body == nil {
|
||||
m.Body = []byte{}
|
||||
}
|
||||
iNdEx = postIndex
|
||||
case 3:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType)
|
||||
}
|
||||
var msglen int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
msglen |= int(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if msglen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + msglen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
if m.Headers == nil {
|
||||
m.Headers = make(map[string]string)
|
||||
}
|
||||
var mapkey string
|
||||
var mapvalue string
|
||||
for iNdEx < postIndex {
|
||||
entryPreIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
if fieldNum == 1 {
|
||||
var stringLenmapkey uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapkey |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapkey := int(stringLenmapkey)
|
||||
if intStringLenmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapkey := iNdEx + intStringLenmapkey
|
||||
if postStringIndexmapkey < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapkey > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapkey = string(dAtA[iNdEx:postStringIndexmapkey])
|
||||
iNdEx = postStringIndexmapkey
|
||||
} else if fieldNum == 2 {
|
||||
var stringLenmapvalue uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLenmapvalue |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLenmapvalue := int(stringLenmapvalue)
|
||||
if intStringLenmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postStringIndexmapvalue := iNdEx + intStringLenmapvalue
|
||||
if postStringIndexmapvalue < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postStringIndexmapvalue > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue])
|
||||
iNdEx = postStringIndexmapvalue
|
||||
} else {
|
||||
iNdEx = entryPreIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > postIndex {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
m.Headers[mapkey] = mapvalue
|
||||
iNdEx = postIndex
|
||||
case 4:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Error = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
212
plugins/host/scheduler/scheduler.pb.go
Normal file
212
plugins/host/scheduler/scheduler.pb.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/scheduler/scheduler.proto
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ScheduleOneTimeRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
DelaySeconds int32 `protobuf:"varint,1,opt,name=delay_seconds,json=delaySeconds,proto3" json:"delay_seconds,omitempty"` // Delay in seconds
|
||||
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback
|
||||
ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) GetDelaySeconds() int32 {
|
||||
if x != nil {
|
||||
return x.DelaySeconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) GetPayload() []byte {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ScheduleOneTimeRequest) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ScheduleRecurringRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CronExpression string `protobuf:"bytes,1,opt,name=cron_expression,json=cronExpression,proto3" json:"cron_expression,omitempty"` // Cron expression (e.g. "0 0 * * *" for daily at midnight)
|
||||
Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback
|
||||
ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) GetCronExpression() string {
|
||||
if x != nil {
|
||||
return x.CronExpression
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) GetPayload() []byte {
|
||||
if x != nil {
|
||||
return x.Payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ScheduleRecurringRequest) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ScheduleResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID to reference this scheduled job
|
||||
}
|
||||
|
||||
func (x *ScheduleResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ScheduleResponse) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the schedule to cancel
|
||||
}
|
||||
|
||||
func (x *CancelRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CancelRequest) GetScheduleId() string {
|
||||
if x != nil {
|
||||
return x.ScheduleId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether cancellation was successful
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Error message if cancellation failed
|
||||
}
|
||||
|
||||
func (x *CancelResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetSuccess() bool {
|
||||
if x != nil {
|
||||
return x.Success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *CancelResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type TimeNowRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *TimeNowRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
type TimeNowResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format
|
||||
UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp
|
||||
LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC")
|
||||
}
|
||||
|
||||
func (x *TimeNowResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *TimeNowResponse) GetRfc3339Nano() string {
|
||||
if x != nil {
|
||||
return x.Rfc3339Nano
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TimeNowResponse) GetUnixMilli() int64 {
|
||||
if x != nil {
|
||||
return x.UnixMilli
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *TimeNowResponse) GetLocalTimeZone() string {
|
||||
if x != nil {
|
||||
return x.LocalTimeZone
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type SchedulerService interface {
|
||||
// One-time event scheduling
|
||||
ScheduleOneTime(context.Context, *ScheduleOneTimeRequest) (*ScheduleResponse, error)
|
||||
// Recurring event scheduling
|
||||
ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error)
|
||||
// Cancel any scheduled job
|
||||
CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error)
|
||||
// Get current time in multiple formats
|
||||
TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error)
|
||||
}
|
||||
55
plugins/host/scheduler/scheduler.proto
Normal file
55
plugins/host/scheduler/scheduler.proto
Normal file
@@ -0,0 +1,55 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package scheduler;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/scheduler;scheduler";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service SchedulerService {
|
||||
// One-time event scheduling
|
||||
rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse);
|
||||
|
||||
// Recurring event scheduling
|
||||
rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse);
|
||||
|
||||
// Cancel any scheduled job
|
||||
rpc CancelSchedule(CancelRequest) returns (CancelResponse);
|
||||
|
||||
// Get current time in multiple formats
|
||||
rpc TimeNow(TimeNowRequest) returns (TimeNowResponse);
|
||||
}
|
||||
|
||||
message ScheduleOneTimeRequest {
|
||||
int32 delay_seconds = 1; // Delay in seconds
|
||||
bytes payload = 2; // Serialized data to pass to the callback
|
||||
string schedule_id = 3; // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
message ScheduleRecurringRequest {
|
||||
string cron_expression = 1; // Cron expression (e.g. "0 0 * * *" for daily at midnight)
|
||||
bytes payload = 2; // Serialized data to pass to the callback
|
||||
string schedule_id = 3; // Optional custom ID (if not provided, one will be generated)
|
||||
}
|
||||
|
||||
message ScheduleResponse {
|
||||
string schedule_id = 1; // ID to reference this scheduled job
|
||||
}
|
||||
|
||||
message CancelRequest {
|
||||
string schedule_id = 1; // ID of the schedule to cancel
|
||||
}
|
||||
|
||||
message CancelResponse {
|
||||
bool success = 1; // Whether cancellation was successful
|
||||
string error = 2; // Error message if cancellation failed
|
||||
}
|
||||
|
||||
message TimeNowRequest {
|
||||
// Empty request - no parameters needed
|
||||
}
|
||||
|
||||
message TimeNowResponse {
|
||||
string rfc3339_nano = 1; // Current time in RFC3339Nano format
|
||||
int64 unix_milli = 2; // Current time as Unix milliseconds timestamp
|
||||
string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC")
|
||||
}
|
||||
170
plugins/host/scheduler/scheduler_host.pb.go
Normal file
170
plugins/host/scheduler/scheduler_host.pb.go
Normal file
@@ -0,0 +1,170 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/scheduler/scheduler.proto
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _schedulerService struct {
|
||||
SchedulerService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _schedulerService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._ScheduleOneTime), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("schedule_one_time")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._ScheduleRecurring), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("schedule_recurring")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._CancelSchedule), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("cancel_schedule")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("time_now")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// One-time event scheduling
|
||||
|
||||
func (h _schedulerService) _ScheduleOneTime(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(ScheduleOneTimeRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.ScheduleOneTime(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Recurring event scheduling
|
||||
|
||||
func (h _schedulerService) _ScheduleRecurring(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(ScheduleRecurringRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.ScheduleRecurring(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Cancel any scheduled job
|
||||
|
||||
func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(CancelRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.CancelSchedule(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Get current time in multiple formats
|
||||
|
||||
func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(TimeNowRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.TimeNow(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
113
plugins/host/scheduler/scheduler_plugin.pb.go
Normal file
113
plugins/host/scheduler/scheduler_plugin.pb.go
Normal file
@@ -0,0 +1,113 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/scheduler/scheduler.proto
|
||||
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type schedulerService struct{}
|
||||
|
||||
func NewSchedulerService() SchedulerService {
|
||||
return schedulerService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env schedule_one_time
|
||||
func _schedule_one_time(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h schedulerService) ScheduleOneTime(ctx context.Context, request *ScheduleOneTimeRequest) (*ScheduleResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _schedule_one_time(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(ScheduleResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env schedule_recurring
|
||||
func _schedule_recurring(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h schedulerService) ScheduleRecurring(ctx context.Context, request *ScheduleRecurringRequest) (*ScheduleResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _schedule_recurring(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(ScheduleResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env cancel_schedule
|
||||
func _cancel_schedule(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelRequest) (*CancelResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _cancel_schedule(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(CancelResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env time_now
|
||||
func _time_now(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _time_now(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(TimeNowResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/scheduler/scheduler_plugin_dev.go
Normal file
7
plugins/host/scheduler/scheduler_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package scheduler
|
||||
|
||||
func NewSchedulerService() SchedulerService {
|
||||
panic("not implemented")
|
||||
}
|
||||
1303
plugins/host/scheduler/scheduler_vtproto.pb.go
Normal file
1303
plugins/host/scheduler/scheduler_vtproto.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
71
plugins/host/subsonicapi/subsonicapi.pb.go
Normal file
71
plugins/host/subsonicapi/subsonicapi.pb.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type CallRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CallRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CallRequest) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CallResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Json string `protobuf:"bytes,1,opt,name=json,proto3" json:"json,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if operation failed
|
||||
}
|
||||
|
||||
func (x *CallResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CallResponse) GetJson() string {
|
||||
if x != nil {
|
||||
return x.Json
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CallResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type SubsonicAPIService interface {
|
||||
Call(context.Context, *CallRequest) (*CallResponse, error)
|
||||
}
|
||||
19
plugins/host/subsonicapi/subsonicapi.proto
Normal file
19
plugins/host/subsonicapi/subsonicapi.proto
Normal file
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package subsonicapi;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/subsonicapi;subsonicapi";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service SubsonicAPIService {
|
||||
rpc Call(CallRequest) returns (CallResponse);
|
||||
}
|
||||
|
||||
message CallRequest {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message CallResponse {
|
||||
string json = 1;
|
||||
string error = 2; // Non-empty if operation failed
|
||||
}
|
||||
66
plugins/host/subsonicapi/subsonicapi_host.pb.go
Normal file
66
plugins/host/subsonicapi/subsonicapi_host.pb.go
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _subsonicAPIService struct {
|
||||
SubsonicAPIService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SubsonicAPIService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _subsonicAPIService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Call), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("call")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h _subsonicAPIService) _Call(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(CallRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Call(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
44
plugins/host/subsonicapi/subsonicapi_plugin.pb.go
Normal file
44
plugins/host/subsonicapi/subsonicapi_plugin.pb.go
Normal file
@@ -0,0 +1,44 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type subsonicAPIService struct{}
|
||||
|
||||
func NewSubsonicAPIService() SubsonicAPIService {
|
||||
return subsonicAPIService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env call
|
||||
func _call(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h subsonicAPIService) Call(ctx context.Context, request *CallRequest) (*CallResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _call(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(CallResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
441
plugins/host/subsonicapi/subsonicapi_vtproto.pb.go
Normal file
441
plugins/host/subsonicapi/subsonicapi_vtproto.pb.go
Normal file
@@ -0,0 +1,441 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/subsonicapi/subsonicapi.proto
|
||||
|
||||
package subsonicapi
|
||||
|
||||
import (
|
||||
fmt "fmt"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
io "io"
|
||||
bits "math/bits"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
func (m *CallRequest) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *CallRequest) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *CallRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Url) > 0 {
|
||||
i -= len(m.Url)
|
||||
copy(dAtA[i:], m.Url)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Url)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func (m *CallResponse) MarshalVT() (dAtA []byte, err error) {
|
||||
if m == nil {
|
||||
return nil, nil
|
||||
}
|
||||
size := m.SizeVT()
|
||||
dAtA = make([]byte, size)
|
||||
n, err := m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dAtA[:n], nil
|
||||
}
|
||||
|
||||
func (m *CallResponse) MarshalToVT(dAtA []byte) (int, error) {
|
||||
size := m.SizeVT()
|
||||
return m.MarshalToSizedBufferVT(dAtA[:size])
|
||||
}
|
||||
|
||||
func (m *CallResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
|
||||
if m == nil {
|
||||
return 0, nil
|
||||
}
|
||||
i := len(dAtA)
|
||||
_ = i
|
||||
var l int
|
||||
_ = l
|
||||
if m.unknownFields != nil {
|
||||
i -= len(m.unknownFields)
|
||||
copy(dAtA[i:], m.unknownFields)
|
||||
}
|
||||
if len(m.Error) > 0 {
|
||||
i -= len(m.Error)
|
||||
copy(dAtA[i:], m.Error)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Error)))
|
||||
i--
|
||||
dAtA[i] = 0x12
|
||||
}
|
||||
if len(m.Json) > 0 {
|
||||
i -= len(m.Json)
|
||||
copy(dAtA[i:], m.Json)
|
||||
i = encodeVarint(dAtA, i, uint64(len(m.Json)))
|
||||
i--
|
||||
dAtA[i] = 0xa
|
||||
}
|
||||
return len(dAtA) - i, nil
|
||||
}
|
||||
|
||||
func encodeVarint(dAtA []byte, offset int, v uint64) int {
|
||||
offset -= sov(v)
|
||||
base := offset
|
||||
for v >= 1<<7 {
|
||||
dAtA[offset] = uint8(v&0x7f | 0x80)
|
||||
v >>= 7
|
||||
offset++
|
||||
}
|
||||
dAtA[offset] = uint8(v)
|
||||
return base
|
||||
}
|
||||
func (m *CallRequest) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Url)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func (m *CallResponse) SizeVT() (n int) {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
var l int
|
||||
_ = l
|
||||
l = len(m.Json)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
l = len(m.Error)
|
||||
if l > 0 {
|
||||
n += 1 + l + sov(uint64(l))
|
||||
}
|
||||
n += len(m.unknownFields)
|
||||
return n
|
||||
}
|
||||
|
||||
func sov(x uint64) (n int) {
|
||||
return (bits.Len64(x|1) + 6) / 7
|
||||
}
|
||||
func soz(x uint64) (n int) {
|
||||
return sov(uint64((x << 1) ^ uint64((int64(x) >> 63))))
|
||||
}
|
||||
func (m *CallRequest) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: CallRequest: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: CallRequest: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Url = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *CallResponse) UnmarshalVT(dAtA []byte) error {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
for iNdEx < l {
|
||||
preIndex := iNdEx
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
fieldNum := int32(wire >> 3)
|
||||
wireType := int(wire & 0x7)
|
||||
if wireType == 4 {
|
||||
return fmt.Errorf("proto: CallResponse: wiretype end group for non-group")
|
||||
}
|
||||
if fieldNum <= 0 {
|
||||
return fmt.Errorf("proto: CallResponse: illegal tag %d (wire type %d)", fieldNum, wire)
|
||||
}
|
||||
switch fieldNum {
|
||||
case 1:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Json", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Json = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
case 2:
|
||||
if wireType != 2 {
|
||||
return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType)
|
||||
}
|
||||
var stringLen uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
stringLen |= uint64(b&0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
intStringLen := int(stringLen)
|
||||
if intStringLen < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
postIndex := iNdEx + intStringLen
|
||||
if postIndex < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if postIndex > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.Error = string(dAtA[iNdEx:postIndex])
|
||||
iNdEx = postIndex
|
||||
default:
|
||||
iNdEx = preIndex
|
||||
skippy, err := skip(dAtA[iNdEx:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (skippy < 0) || (iNdEx+skippy) < 0 {
|
||||
return ErrInvalidLength
|
||||
}
|
||||
if (iNdEx + skippy) > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...)
|
||||
iNdEx += skippy
|
||||
}
|
||||
}
|
||||
|
||||
if iNdEx > l {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skip(dAtA []byte) (n int, err error) {
|
||||
l := len(dAtA)
|
||||
iNdEx := 0
|
||||
depth := 0
|
||||
for iNdEx < l {
|
||||
var wire uint64
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
wire |= (uint64(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
wireType := int(wire & 0x7)
|
||||
switch wireType {
|
||||
case 0:
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
iNdEx++
|
||||
if dAtA[iNdEx-1] < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
iNdEx += 8
|
||||
case 2:
|
||||
var length int
|
||||
for shift := uint(0); ; shift += 7 {
|
||||
if shift >= 64 {
|
||||
return 0, ErrIntOverflow
|
||||
}
|
||||
if iNdEx >= l {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := dAtA[iNdEx]
|
||||
iNdEx++
|
||||
length |= (int(b) & 0x7F) << shift
|
||||
if b < 0x80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if length < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
iNdEx += length
|
||||
case 3:
|
||||
depth++
|
||||
case 4:
|
||||
if depth == 0 {
|
||||
return 0, ErrUnexpectedEndOfGroup
|
||||
}
|
||||
depth--
|
||||
case 5:
|
||||
iNdEx += 4
|
||||
default:
|
||||
return 0, fmt.Errorf("proto: illegal wireType %d", wireType)
|
||||
}
|
||||
if iNdEx < 0 {
|
||||
return 0, ErrInvalidLength
|
||||
}
|
||||
if depth == 0 {
|
||||
return iNdEx, nil
|
||||
}
|
||||
}
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling")
|
||||
ErrIntOverflow = fmt.Errorf("proto: integer overflow")
|
||||
ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group")
|
||||
)
|
||||
240
plugins/host/websocket/websocket.pb.go
Normal file
240
plugins/host/websocket/websocket.pb.go
Normal file
@@ -0,0 +1,240 @@
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/websocket/websocket.proto
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
context "context"
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type ConnectRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"`
|
||||
Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
|
||||
ConnectionId string `protobuf:"bytes,3,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) GetUrl() string {
|
||||
if x != nil {
|
||||
return x.Url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) GetHeaders() map[string]string {
|
||||
if x != nil {
|
||||
return x.Headers
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ConnectRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ConnectResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ConnectResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *ConnectResponse) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ConnectResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendTextRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendTextRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendTextRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SendTextRequest) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendTextResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendTextResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendTextResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SendBinaryRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendBinaryRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendBinaryRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SendBinaryRequest) GetData() []byte {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SendBinaryResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SendBinaryResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *SendBinaryResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CloseRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"`
|
||||
Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"`
|
||||
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CloseRequest) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CloseRequest) GetConnectionId() string {
|
||||
if x != nil {
|
||||
return x.ConnectionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *CloseRequest) GetCode() int32 {
|
||||
if x != nil {
|
||||
return x.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *CloseRequest) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type CloseResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (x *CloseResponse) ProtoReflect() protoreflect.Message {
|
||||
panic(`not implemented`)
|
||||
}
|
||||
|
||||
func (x *CloseResponse) GetError() string {
|
||||
if x != nil {
|
||||
return x.Error
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// go:plugin type=host version=1
|
||||
type WebSocketService interface {
|
||||
// Connect to a WebSocket endpoint
|
||||
Connect(context.Context, *ConnectRequest) (*ConnectResponse, error)
|
||||
// Send a text message
|
||||
SendText(context.Context, *SendTextRequest) (*SendTextResponse, error)
|
||||
// Send binary data
|
||||
SendBinary(context.Context, *SendBinaryRequest) (*SendBinaryResponse, error)
|
||||
// Close a connection
|
||||
Close(context.Context, *CloseRequest) (*CloseResponse, error)
|
||||
}
|
||||
57
plugins/host/websocket/websocket.proto
Normal file
57
plugins/host/websocket/websocket.proto
Normal file
@@ -0,0 +1,57 @@
|
||||
syntax = "proto3";
|
||||
package websocket;
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/host/websocket";
|
||||
|
||||
// go:plugin type=host version=1
|
||||
service WebSocketService {
|
||||
// Connect to a WebSocket endpoint
|
||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||
|
||||
// Send a text message
|
||||
rpc SendText(SendTextRequest) returns (SendTextResponse);
|
||||
|
||||
// Send binary data
|
||||
rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse);
|
||||
|
||||
// Close a connection
|
||||
rpc Close(CloseRequest) returns (CloseResponse);
|
||||
}
|
||||
|
||||
message ConnectRequest {
|
||||
string url = 1;
|
||||
map<string, string> headers = 2;
|
||||
string connection_id = 3;
|
||||
}
|
||||
|
||||
message ConnectResponse {
|
||||
string connection_id = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message SendTextRequest {
|
||||
string connection_id = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message SendTextResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message SendBinaryRequest {
|
||||
string connection_id = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message SendBinaryResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message CloseRequest {
|
||||
string connection_id = 1;
|
||||
int32 code = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message CloseResponse {
|
||||
string error = 1;
|
||||
}
|
||||
170
plugins/host/websocket/websocket_host.pb.go
Normal file
170
plugins/host/websocket/websocket_host.pb.go
Normal file
@@ -0,0 +1,170 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/websocket/websocket.proto
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
api "github.com/tetratelabs/wazero/api"
|
||||
)
|
||||
|
||||
const (
|
||||
i32 = api.ValueTypeI32
|
||||
i64 = api.ValueTypeI64
|
||||
)
|
||||
|
||||
type _webSocketService struct {
|
||||
WebSocketService
|
||||
}
|
||||
|
||||
// Instantiate a Go-defined module named "env" that exports host functions.
|
||||
func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions WebSocketService) error {
|
||||
envBuilder := r.NewHostModuleBuilder("env")
|
||||
h := _webSocketService{hostFunctions}
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Connect), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("connect")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._SendText), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("send_text")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._SendBinary), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("send_binary")
|
||||
|
||||
envBuilder.NewFunctionBuilder().
|
||||
WithGoModuleFunction(api.GoModuleFunc(h._Close), []api.ValueType{i32, i32}, []api.ValueType{i64}).
|
||||
WithParameterNames("offset", "size").
|
||||
Export("close")
|
||||
|
||||
_, err := envBuilder.Instantiate(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Connect to a WebSocket endpoint
|
||||
|
||||
func (h _webSocketService) _Connect(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(ConnectRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Connect(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Send a text message
|
||||
|
||||
func (h _webSocketService) _SendText(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(SendTextRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.SendText(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Send binary data
|
||||
|
||||
func (h _webSocketService) _SendBinary(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(SendBinaryRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.SendBinary(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
|
||||
// Close a connection
|
||||
|
||||
func (h _webSocketService) _Close(ctx context.Context, m api.Module, stack []uint64) {
|
||||
offset, size := uint32(stack[0]), uint32(stack[1])
|
||||
buf, err := wasm.ReadMemory(m.Memory(), offset, size)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
request := new(CloseRequest)
|
||||
err = request.UnmarshalVT(buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resp, err := h.Close(ctx, request)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
buf, err = resp.MarshalVT()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptr, err := wasm.WriteMemory(ctx, m, buf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ptrLen := (ptr << uint64(32)) | uint64(len(buf))
|
||||
stack[0] = ptrLen
|
||||
}
|
||||
113
plugins/host/websocket/websocket_plugin.pb.go
Normal file
113
plugins/host/websocket/websocket_plugin.pb.go
Normal file
@@ -0,0 +1,113 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: host/websocket/websocket.proto
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
_ "unsafe"
|
||||
)
|
||||
|
||||
type webSocketService struct{}
|
||||
|
||||
func NewWebSocketService() WebSocketService {
|
||||
return webSocketService{}
|
||||
}
|
||||
|
||||
//go:wasmimport env connect
|
||||
func _connect(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h webSocketService) Connect(ctx context.Context, request *ConnectRequest) (*ConnectResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _connect(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(ConnectResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env send_text
|
||||
func _send_text(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h webSocketService) SendText(ctx context.Context, request *SendTextRequest) (*SendTextResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _send_text(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SendTextResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env send_binary
|
||||
func _send_binary(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h webSocketService) SendBinary(ctx context.Context, request *SendBinaryRequest) (*SendBinaryResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _send_binary(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(SendBinaryResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
//go:wasmimport env close
|
||||
func _close(ptr uint32, size uint32) uint64
|
||||
|
||||
func (h webSocketService) Close(ctx context.Context, request *CloseRequest) (*CloseResponse, error) {
|
||||
buf, err := request.MarshalVT()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr, size := wasm.ByteToPtr(buf)
|
||||
ptrSize := _close(ptr, size)
|
||||
wasm.Free(ptr)
|
||||
|
||||
ptr = uint32(ptrSize >> 32)
|
||||
size = uint32(ptrSize)
|
||||
buf = wasm.PtrToByte(ptr, size)
|
||||
|
||||
response := new(CloseResponse)
|
||||
if err = response.UnmarshalVT(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
7
plugins/host/websocket/websocket_plugin_dev.go
Normal file
7
plugins/host/websocket/websocket_plugin_dev.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package websocket
|
||||
|
||||
func NewWebSocketService() WebSocketService {
|
||||
panic("not implemented")
|
||||
}
|
||||
1618
plugins/host/websocket/websocket_vtproto.pb.go
Normal file
1618
plugins/host/websocket/websocket_vtproto.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
47
plugins/host_artwork.go
Normal file
47
plugins/host_artwork.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/host/artwork"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
)
|
||||
|
||||
type artworkServiceImpl struct{}
|
||||
|
||||
func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) {
|
||||
artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: req.Id}
|
||||
imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size))
|
||||
return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil
|
||||
}
|
||||
|
||||
func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) {
|
||||
artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: req.Id}
|
||||
imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size))
|
||||
return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil
|
||||
}
|
||||
|
||||
func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) {
|
||||
artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: req.Id}
|
||||
imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size))
|
||||
return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil
|
||||
}
|
||||
|
||||
func (a *artworkServiceImpl) createRequest() *http.Request {
|
||||
var scheme, host string
|
||||
if conf.Server.ShareURL != "" {
|
||||
shareURL, _ := url.Parse(conf.Server.ShareURL)
|
||||
scheme = shareURL.Scheme
|
||||
host = shareURL.Host
|
||||
} else {
|
||||
scheme = "http"
|
||||
host = "localhost"
|
||||
}
|
||||
r, _ := http.NewRequest("GET", fmt.Sprintf("%s://%s", scheme, host), nil)
|
||||
return r
|
||||
}
|
||||
58
plugins/host_artwork_test.go
Normal file
58
plugins/host_artwork_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/plugins/host/artwork"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("ArtworkService", func() {
|
||||
var svc *artworkServiceImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Setup auth for tests
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil)
|
||||
svc = &artworkServiceImpl{}
|
||||
})
|
||||
|
||||
Context("with ShareURL configured", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://music.example.com"
|
||||
})
|
||||
|
||||
It("returns artist artwork URL", func() {
|
||||
resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123", Size: 300})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Url).To(ContainSubstring("https://music.example.com"))
|
||||
Expect(resp.Url).To(ContainSubstring("size=300"))
|
||||
})
|
||||
|
||||
It("returns album artwork URL", func() {
|
||||
resp, err := svc.GetAlbumUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "456"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Url).To(ContainSubstring("https://music.example.com"))
|
||||
})
|
||||
|
||||
It("returns track artwork URL", func() {
|
||||
resp, err := svc.GetTrackUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "789", Size: 150})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Url).To(ContainSubstring("https://music.example.com"))
|
||||
Expect(resp.Url).To(ContainSubstring("size=150"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("without ShareURL configured", func() {
|
||||
It("returns localhost URLs", func() {
|
||||
resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Url).To(ContainSubstring("http://localhost"))
|
||||
})
|
||||
})
|
||||
})
|
||||
152
plugins/host_cache.go
Normal file
152
plugins/host_cache.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
cacheproto "github.com/navidrome/navidrome/plugins/host/cache"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// cacheServiceImpl implements the cache.CacheService interface
|
||||
type cacheServiceImpl struct {
|
||||
pluginID string
|
||||
defaultTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
_cache *ttlcache.Cache[string, any]
|
||||
initCacheOnce sync.Once
|
||||
)
|
||||
|
||||
// newCacheService creates a new cacheServiceImpl instance
|
||||
func newCacheService(pluginID string) *cacheServiceImpl {
|
||||
initCacheOnce.Do(func() {
|
||||
opts := []ttlcache.Option[string, any]{
|
||||
ttlcache.WithTTL[string, any](defaultCacheTTL),
|
||||
}
|
||||
_cache = ttlcache.New[string, any](opts...)
|
||||
|
||||
// Start the janitor goroutine to clean up expired entries
|
||||
go _cache.Start()
|
||||
})
|
||||
|
||||
return &cacheServiceImpl{
|
||||
pluginID: pluginID,
|
||||
defaultTTL: defaultCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// mapKey combines the plugin name and a provided key to create a unique cache key.
|
||||
func (s *cacheServiceImpl) mapKey(key string) string {
|
||||
return s.pluginID + ":" + key
|
||||
}
|
||||
|
||||
// getTTL converts seconds to a duration, using default if 0
|
||||
func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return s.defaultTTL
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// setCacheValue is a generic function to set a value in the cache
|
||||
func setCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, value T, ttlSeconds int64) (*cacheproto.SetResponse, error) {
|
||||
ttl := cs.getTTL(ttlSeconds)
|
||||
key = cs.mapKey(key)
|
||||
_cache.Set(key, value, ttl)
|
||||
return &cacheproto.SetResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
// getCacheValue is a generic function to get a value from the cache
|
||||
func getCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, typeName string) (T, bool, error) {
|
||||
key = cs.mapKey(key)
|
||||
var zero T
|
||||
item := _cache.Get(key)
|
||||
if item == nil {
|
||||
return zero, false, nil
|
||||
}
|
||||
|
||||
value, ok := item.Value().(T)
|
||||
if !ok {
|
||||
log.Debug(ctx, "Type mismatch in cache", "plugin", cs.pluginID, "key", key, "expected", typeName)
|
||||
return zero, false, nil
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// SetString sets a string value in the cache
|
||||
func (s *cacheServiceImpl) SetString(ctx context.Context, req *cacheproto.SetStringRequest) (*cacheproto.SetResponse, error) {
|
||||
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
|
||||
}
|
||||
|
||||
// GetString gets a string value from the cache
|
||||
func (s *cacheServiceImpl) GetString(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetStringResponse, error) {
|
||||
value, exists, err := getCacheValue[string](ctx, s, req.Key, "string")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cacheproto.GetStringResponse{Exists: exists, Value: value}, nil
|
||||
}
|
||||
|
||||
// SetInt sets an integer value in the cache
|
||||
func (s *cacheServiceImpl) SetInt(ctx context.Context, req *cacheproto.SetIntRequest) (*cacheproto.SetResponse, error) {
|
||||
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
|
||||
}
|
||||
|
||||
// GetInt gets an integer value from the cache
|
||||
func (s *cacheServiceImpl) GetInt(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetIntResponse, error) {
|
||||
value, exists, err := getCacheValue[int64](ctx, s, req.Key, "int64")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cacheproto.GetIntResponse{Exists: exists, Value: value}, nil
|
||||
}
|
||||
|
||||
// SetFloat sets a float value in the cache
|
||||
func (s *cacheServiceImpl) SetFloat(ctx context.Context, req *cacheproto.SetFloatRequest) (*cacheproto.SetResponse, error) {
|
||||
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
|
||||
}
|
||||
|
||||
// GetFloat gets a float value from the cache
|
||||
func (s *cacheServiceImpl) GetFloat(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetFloatResponse, error) {
|
||||
value, exists, err := getCacheValue[float64](ctx, s, req.Key, "float64")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cacheproto.GetFloatResponse{Exists: exists, Value: value}, nil
|
||||
}
|
||||
|
||||
// SetBytes sets a byte slice value in the cache
|
||||
func (s *cacheServiceImpl) SetBytes(ctx context.Context, req *cacheproto.SetBytesRequest) (*cacheproto.SetResponse, error) {
|
||||
return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds)
|
||||
}
|
||||
|
||||
// GetBytes gets a byte slice value from the cache
|
||||
func (s *cacheServiceImpl) GetBytes(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetBytesResponse, error) {
|
||||
value, exists, err := getCacheValue[[]byte](ctx, s, req.Key, "[]byte")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cacheproto.GetBytesResponse{Exists: exists, Value: value}, nil
|
||||
}
|
||||
|
||||
// Remove removes a value from the cache
|
||||
func (s *cacheServiceImpl) Remove(ctx context.Context, req *cacheproto.RemoveRequest) (*cacheproto.RemoveResponse, error) {
|
||||
key := s.mapKey(req.Key)
|
||||
_cache.Delete(key)
|
||||
return &cacheproto.RemoveResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
// Has checks if a key exists in the cache
|
||||
func (s *cacheServiceImpl) Has(ctx context.Context, req *cacheproto.HasRequest) (*cacheproto.HasResponse, error) {
|
||||
key := s.mapKey(req.Key)
|
||||
item := _cache.Get(key)
|
||||
return &cacheproto.HasResponse{Exists: item != nil}, nil
|
||||
}
|
||||
171
plugins/host_cache_test.go
Normal file
171
plugins/host_cache_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/host/cache"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("CacheService", func() {
|
||||
var service *cacheServiceImpl
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
service = newCacheService("test_plugin")
|
||||
})
|
||||
|
||||
Describe("getTTL", func() {
|
||||
It("returns default TTL when seconds is 0", func() {
|
||||
ttl := service.getTTL(0)
|
||||
Expect(ttl).To(Equal(defaultCacheTTL))
|
||||
})
|
||||
|
||||
It("returns default TTL when seconds is negative", func() {
|
||||
ttl := service.getTTL(-10)
|
||||
Expect(ttl).To(Equal(defaultCacheTTL))
|
||||
})
|
||||
|
||||
It("returns correct duration when seconds is positive", func() {
|
||||
ttl := service.getTTL(60)
|
||||
Expect(ttl).To(Equal(time.Minute))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("String Operations", func() {
|
||||
It("sets and gets a string value", func() {
|
||||
_, err := service.SetString(ctx, &cache.SetStringRequest{
|
||||
Key: "string_key",
|
||||
Value: "test_value",
|
||||
TtlSeconds: 300,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
res, err := service.GetString(ctx, &cache.GetRequest{Key: "string_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeTrue())
|
||||
Expect(res.Value).To(Equal("test_value"))
|
||||
})
|
||||
|
||||
It("returns not exists for missing key", func() {
|
||||
res, err := service.GetString(ctx, &cache.GetRequest{Key: "missing_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Integer Operations", func() {
|
||||
It("sets and gets an integer value", func() {
|
||||
_, err := service.SetInt(ctx, &cache.SetIntRequest{
|
||||
Key: "int_key",
|
||||
Value: 42,
|
||||
TtlSeconds: 300,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
res, err := service.GetInt(ctx, &cache.GetRequest{Key: "int_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeTrue())
|
||||
Expect(res.Value).To(Equal(int64(42)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Float Operations", func() {
|
||||
It("sets and gets a float value", func() {
|
||||
_, err := service.SetFloat(ctx, &cache.SetFloatRequest{
|
||||
Key: "float_key",
|
||||
Value: 3.14,
|
||||
TtlSeconds: 300,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
res, err := service.GetFloat(ctx, &cache.GetRequest{Key: "float_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeTrue())
|
||||
Expect(res.Value).To(Equal(3.14))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Bytes Operations", func() {
|
||||
It("sets and gets a bytes value", func() {
|
||||
byteData := []byte("hello world")
|
||||
_, err := service.SetBytes(ctx, &cache.SetBytesRequest{
|
||||
Key: "bytes_key",
|
||||
Value: byteData,
|
||||
TtlSeconds: 300,
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
res, err := service.GetBytes(ctx, &cache.GetRequest{Key: "bytes_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeTrue())
|
||||
Expect(res.Value).To(Equal(byteData))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Type mismatch handling", func() {
|
||||
It("returns not exists when type doesn't match the getter", func() {
|
||||
// Set string
|
||||
_, err := service.SetString(ctx, &cache.SetStringRequest{
|
||||
Key: "mixed_key",
|
||||
Value: "string value",
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Try to get as int
|
||||
res, err := service.GetInt(ctx, &cache.GetRequest{Key: "mixed_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Remove Operation", func() {
|
||||
It("removes a value from the cache", func() {
|
||||
// Set a value
|
||||
_, err := service.SetString(ctx, &cache.SetStringRequest{
|
||||
Key: "remove_key",
|
||||
Value: "to be removed",
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify it exists
|
||||
res, err := service.Has(ctx, &cache.HasRequest{Key: "remove_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeTrue())
|
||||
|
||||
// Remove it
|
||||
_, err = service.Remove(ctx, &cache.RemoveRequest{Key: "remove_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify it's gone
|
||||
res, err = service.Has(ctx, &cache.HasRequest{Key: "remove_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Has Operation", func() {
|
||||
It("returns true for existing key", func() {
|
||||
// Set a value
|
||||
_, err := service.SetString(ctx, &cache.SetStringRequest{
|
||||
Key: "existing_key",
|
||||
Value: "exists",
|
||||
})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Check if it exists
|
||||
res, err := service.Has(ctx, &cache.HasRequest{Key: "existing_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for non-existing key", func() {
|
||||
res, err := service.Has(ctx, &cache.HasRequest{Key: "non_existing_key"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(res.Exists).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
22
plugins/host_config.go
Normal file
22
plugins/host_config.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/plugins/host/config"
|
||||
)
|
||||
|
||||
type configServiceImpl struct {
|
||||
pluginID string
|
||||
}
|
||||
|
||||
func (c *configServiceImpl) GetPluginConfig(ctx context.Context, req *config.GetPluginConfigRequest) (*config.GetPluginConfigResponse, error) {
|
||||
cfg, ok := conf.Server.PluginConfig[c.pluginID]
|
||||
if !ok {
|
||||
cfg = map[string]string{}
|
||||
}
|
||||
return &config.GetPluginConfigResponse{
|
||||
Config: cfg,
|
||||
}, nil
|
||||
}
|
||||
46
plugins/host_config_test.go
Normal file
46
plugins/host_config_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
hostconfig "github.com/navidrome/navidrome/plugins/host/config"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("configServiceImpl", func() {
|
||||
var (
|
||||
svc *configServiceImpl
|
||||
pluginName string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
pluginName = "testplugin"
|
||||
svc = &configServiceImpl{pluginID: pluginName}
|
||||
conf.Server.PluginConfig = map[string]map[string]string{
|
||||
pluginName: {"foo": "bar", "baz": "qux"},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns config for known plugin", func() {
|
||||
resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Config).To(HaveKeyWithValue("foo", "bar"))
|
||||
Expect(resp.Config).To(HaveKeyWithValue("baz", "qux"))
|
||||
})
|
||||
|
||||
It("returns error for unknown plugin", func() {
|
||||
svc.pluginID = "unknown"
|
||||
resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Config).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty config if plugin config is empty", func() {
|
||||
conf.Server.PluginConfig[pluginName] = map[string]string{}
|
||||
resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Config).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
114
plugins/host_http.go
Normal file
114
plugins/host_http.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
hosthttp "github.com/navidrome/navidrome/plugins/host/http"
|
||||
)
|
||||
|
||||
type httpServiceImpl struct {
|
||||
pluginID string
|
||||
permissions *httpPermissions
|
||||
}
|
||||
|
||||
const defaultTimeout = 10 * time.Second
|
||||
|
||||
func (s *httpServiceImpl) Get(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodGet, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) Post(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodPost, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) Put(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodPut, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) Delete(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodDelete, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) Patch(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodPatch, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) Head(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodHead, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) Options(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
return s.doHttp(ctx, http.MethodOptions, req)
|
||||
}
|
||||
|
||||
func (s *httpServiceImpl) doHttp(ctx context.Context, method string, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) {
|
||||
// Check permissions if they exist
|
||||
if s.permissions != nil {
|
||||
if err := s.permissions.IsRequestAllowed(req.Url, method); err != nil {
|
||||
log.Warn(ctx, "HTTP request blocked by permissions", "plugin", s.pluginID, "url", req.Url, "method", method, err)
|
||||
return &hosthttp.HttpResponse{Error: "Request blocked by plugin permissions: " + err.Error()}, nil
|
||||
}
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: cmp.Or(time.Duration(req.TimeoutMs)*time.Millisecond, defaultTimeout),
|
||||
}
|
||||
|
||||
// Configure redirect policy based on permissions
|
||||
if s.permissions != nil {
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
// Enforce maximum redirect limit
|
||||
if len(via) >= httpMaxRedirects {
|
||||
log.Warn(ctx, "HTTP redirect limit exceeded", "plugin", s.pluginID, "url", req.URL.String(), "redirectCount", len(via))
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
// Check if redirect destination is allowed
|
||||
if err := s.permissions.IsRequestAllowed(req.URL.String(), req.Method); err != nil {
|
||||
log.Warn(ctx, "HTTP redirect blocked by permissions", "plugin", s.pluginID, "url", req.URL.String(), "method", req.Method, err)
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
return nil // Allow redirect
|
||||
}
|
||||
}
|
||||
var body io.Reader
|
||||
if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch {
|
||||
body = bytes.NewReader(req.Body)
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, req.Url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range req.Headers {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, err)
|
||||
return &hosthttp.HttpResponse{Error: err.Error()}, nil
|
||||
}
|
||||
log.Trace(ctx, "HttpService request", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode)
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode, err)
|
||||
return &hosthttp.HttpResponse{Error: err.Error()}, nil
|
||||
}
|
||||
headers := map[string]string{}
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
return &hosthttp.HttpResponse{
|
||||
Status: int32(resp.StatusCode),
|
||||
Body: respBody,
|
||||
Headers: headers,
|
||||
}, nil
|
||||
}
|
||||
90
plugins/host_http_permissions.go
Normal file
90
plugins/host_http_permissions.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
)
|
||||
|
||||
// Maximum number of HTTP redirects allowed for plugin requests
|
||||
const httpMaxRedirects = 5
|
||||
|
||||
// HTTPPermissions represents granular HTTP access permissions for plugins
|
||||
type httpPermissions struct {
|
||||
*networkPermissionsBase
|
||||
AllowedUrls map[string][]string `json:"allowedUrls"`
|
||||
matcher *urlMatcher
|
||||
}
|
||||
|
||||
// parseHTTPPermissions extracts HTTP permissions from the schema
|
||||
func parseHTTPPermissions(permData *schema.PluginManifestPermissionsHttp) (*httpPermissions, error) {
|
||||
base := &networkPermissionsBase{
|
||||
AllowLocalNetwork: permData.AllowLocalNetwork,
|
||||
}
|
||||
|
||||
if len(permData.AllowedUrls) == 0 {
|
||||
return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern")
|
||||
}
|
||||
|
||||
allowedUrls := make(map[string][]string)
|
||||
for urlPattern, methodEnums := range permData.AllowedUrls {
|
||||
methods := make([]string, len(methodEnums))
|
||||
for i, methodEnum := range methodEnums {
|
||||
methods[i] = string(methodEnum)
|
||||
}
|
||||
allowedUrls[urlPattern] = methods
|
||||
}
|
||||
|
||||
return &httpPermissions{
|
||||
networkPermissionsBase: base,
|
||||
AllowedUrls: allowedUrls,
|
||||
matcher: newURLMatcher(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsRequestAllowed checks if a specific network request is allowed by the permissions
|
||||
func (p *httpPermissions) IsRequestAllowed(requestURL, operation string) error {
|
||||
if _, err := checkURLPolicy(requestURL, p.AllowLocalNetwork); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// allowedUrls is now required - no fallback to allow all URLs
|
||||
if p.AllowedUrls == nil || len(p.AllowedUrls) == 0 {
|
||||
return fmt.Errorf("no allowed URLs configured for plugin")
|
||||
}
|
||||
|
||||
matcher := newURLMatcher()
|
||||
|
||||
// Check URL patterns and operations
|
||||
// First try exact matches, then wildcard matches
|
||||
operation = strings.ToUpper(operation)
|
||||
|
||||
// Phase 1: Check for exact matches first
|
||||
for urlPattern, allowedOperations := range p.AllowedUrls {
|
||||
if !strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) {
|
||||
// Check if operation is allowed
|
||||
for _, allowedOperation := range allowedOperations {
|
||||
if allowedOperation == "*" || allowedOperation == operation {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Check wildcard patterns
|
||||
for urlPattern, allowedOperations := range p.AllowedUrls {
|
||||
if strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) {
|
||||
// Check if operation is allowed
|
||||
for _, allowedOperation := range allowedOperations {
|
||||
if allowedOperation == "*" || allowedOperation == operation {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL)
|
||||
}
|
||||
187
plugins/host_http_permissions_test.go
Normal file
187
plugins/host_http_permissions_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("HTTP Permissions", func() {
|
||||
Describe("parseHTTPPermissions", func() {
|
||||
It("should parse valid HTTP permissions", func() {
|
||||
permData := &schema.PluginManifestPermissionsHttp{
|
||||
Reason: "Need to fetch album artwork",
|
||||
AllowLocalNetwork: false,
|
||||
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
|
||||
"https://api.example.com/*": {
|
||||
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET,
|
||||
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemPOST,
|
||||
},
|
||||
"https://cdn.example.com/*": {
|
||||
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
perms, err := parseHTTPPermissions(permData)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(perms).ToNot(BeNil())
|
||||
Expect(perms.AllowLocalNetwork).To(BeFalse())
|
||||
Expect(perms.AllowedUrls).To(HaveLen(2))
|
||||
Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"GET", "POST"}))
|
||||
Expect(perms.AllowedUrls["https://cdn.example.com/*"]).To(Equal([]string{"GET"}))
|
||||
})
|
||||
|
||||
It("should fail if allowedUrls is empty", func() {
|
||||
permData := &schema.PluginManifestPermissionsHttp{
|
||||
Reason: "Need to fetch album artwork",
|
||||
AllowLocalNetwork: false,
|
||||
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{},
|
||||
}
|
||||
|
||||
_, err := parseHTTPPermissions(permData)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern"))
|
||||
})
|
||||
|
||||
It("should handle method enum types correctly", func() {
|
||||
permData := &schema.PluginManifestPermissionsHttp{
|
||||
Reason: "Need to fetch album artwork",
|
||||
AllowLocalNetwork: false,
|
||||
AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{
|
||||
"https://api.example.com/*": {
|
||||
schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard, // "*"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
perms, err := parseHTTPPermissions(permData)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"*"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsRequestAllowed", func() {
|
||||
var perms *httpPermissions
|
||||
|
||||
Context("HTTP method-specific validation", func() {
|
||||
BeforeEach(func() {
|
||||
perms = &httpPermissions{
|
||||
networkPermissionsBase: &networkPermissionsBase{
|
||||
Reason: "Test permissions",
|
||||
AllowLocalNetwork: false,
|
||||
},
|
||||
AllowedUrls: map[string][]string{
|
||||
"https://api.example.com": {"GET", "POST"},
|
||||
"https://upload.example.com": {"PUT", "PATCH"},
|
||||
"https://admin.example.com": {"DELETE"},
|
||||
"https://webhook.example.com": {"*"},
|
||||
},
|
||||
matcher: newURLMatcher(),
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("method-specific access control",
|
||||
func(url, method string, shouldSucceed bool) {
|
||||
err := perms.IsRequestAllowed(url, method)
|
||||
if shouldSucceed {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
} else {
|
||||
Expect(err).To(HaveOccurred())
|
||||
}
|
||||
},
|
||||
// Allowed methods
|
||||
Entry("GET to api", "https://api.example.com", "GET", true),
|
||||
Entry("POST to api", "https://api.example.com", "POST", true),
|
||||
Entry("PUT to upload", "https://upload.example.com", "PUT", true),
|
||||
Entry("PATCH to upload", "https://upload.example.com", "PATCH", true),
|
||||
Entry("DELETE to admin", "https://admin.example.com", "DELETE", true),
|
||||
Entry("any method to webhook", "https://webhook.example.com", "OPTIONS", true),
|
||||
Entry("any method to webhook", "https://webhook.example.com", "HEAD", true),
|
||||
|
||||
// Disallowed methods
|
||||
Entry("DELETE to api", "https://api.example.com", "DELETE", false),
|
||||
Entry("GET to upload", "https://upload.example.com", "GET", false),
|
||||
Entry("POST to admin", "https://admin.example.com", "POST", false),
|
||||
)
|
||||
})
|
||||
|
||||
Context("case insensitive method handling", func() {
|
||||
BeforeEach(func() {
|
||||
perms = &httpPermissions{
|
||||
networkPermissionsBase: &networkPermissionsBase{
|
||||
Reason: "Test permissions",
|
||||
AllowLocalNetwork: false,
|
||||
},
|
||||
AllowedUrls: map[string][]string{
|
||||
"https://api.example.com": {"GET", "POST"}, // Both uppercase for consistency
|
||||
},
|
||||
matcher: newURLMatcher(),
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("case insensitive method matching",
|
||||
func(method string, shouldSucceed bool) {
|
||||
err := perms.IsRequestAllowed("https://api.example.com", method)
|
||||
if shouldSucceed {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
} else {
|
||||
Expect(err).To(HaveOccurred())
|
||||
}
|
||||
},
|
||||
Entry("uppercase GET", "GET", true),
|
||||
Entry("lowercase get", "get", true),
|
||||
Entry("mixed case Get", "Get", true),
|
||||
Entry("uppercase POST", "POST", true),
|
||||
Entry("lowercase post", "post", true),
|
||||
Entry("mixed case Post", "Post", true),
|
||||
Entry("disallowed method", "DELETE", false),
|
||||
)
|
||||
})
|
||||
|
||||
Context("with complex URL patterns and HTTP methods", func() {
|
||||
BeforeEach(func() {
|
||||
perms = &httpPermissions{
|
||||
networkPermissionsBase: &networkPermissionsBase{
|
||||
Reason: "Test permissions",
|
||||
AllowLocalNetwork: false,
|
||||
},
|
||||
AllowedUrls: map[string][]string{
|
||||
"https://api.example.com/v1/*": {"GET"},
|
||||
"https://api.example.com/v1/users": {"POST", "PUT"},
|
||||
"https://*.example.com/public/*": {"GET", "HEAD"},
|
||||
"https://admin.*.example.com": {"*"},
|
||||
},
|
||||
matcher: newURLMatcher(),
|
||||
}
|
||||
})
|
||||
|
||||
DescribeTable("complex pattern and method combinations",
|
||||
func(url, method string, shouldSucceed bool) {
|
||||
err := perms.IsRequestAllowed(url, method)
|
||||
if shouldSucceed {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
} else {
|
||||
Expect(err).To(HaveOccurred())
|
||||
}
|
||||
},
|
||||
// Path wildcards with specific methods
|
||||
Entry("GET to v1 path", "https://api.example.com/v1/posts", "GET", true),
|
||||
Entry("POST to v1 path", "https://api.example.com/v1/posts", "POST", false),
|
||||
Entry("POST to specific users endpoint", "https://api.example.com/v1/users", "POST", true),
|
||||
Entry("PUT to specific users endpoint", "https://api.example.com/v1/users", "PUT", true),
|
||||
Entry("DELETE to specific users endpoint", "https://api.example.com/v1/users", "DELETE", false),
|
||||
|
||||
// Subdomain wildcards with specific methods
|
||||
Entry("GET to public path on subdomain", "https://cdn.example.com/public/assets", "GET", true),
|
||||
Entry("HEAD to public path on subdomain", "https://static.example.com/public/files", "HEAD", true),
|
||||
Entry("POST to public path on subdomain", "https://api.example.com/public/upload", "POST", false),
|
||||
|
||||
// Admin subdomain with all methods
|
||||
Entry("GET to admin subdomain", "https://admin.prod.example.com", "GET", true),
|
||||
Entry("POST to admin subdomain", "https://admin.staging.example.com", "POST", true),
|
||||
Entry("DELETE to admin subdomain", "https://admin.dev.example.com", "DELETE", true),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
190
plugins/host_http_test.go
Normal file
190
plugins/host_http_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
hosthttp "github.com/navidrome/navidrome/plugins/host/http"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("httpServiceImpl", func() {
|
||||
var (
|
||||
svc *httpServiceImpl
|
||||
ts *httptest.Server
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
svc = &httpServiceImpl{}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if ts != nil {
|
||||
ts.Close()
|
||||
}
|
||||
})
|
||||
|
||||
It("should handle GET requests", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Test", "ok")
|
||||
w.WriteHeader(201)
|
||||
_, _ = w.Write([]byte("hello"))
|
||||
}))
|
||||
resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
Headers: map[string]string{"A": "B"},
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(resp.Status).To(Equal(int32(201)))
|
||||
Expect(string(resp.Body)).To(Equal("hello"))
|
||||
Expect(resp.Headers["X-Test"]).To(Equal("ok"))
|
||||
})
|
||||
|
||||
It("should handle POST requests with body", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
_, _ = w.Write([]byte("got:" + string(b)))
|
||||
}))
|
||||
resp, err := svc.Post(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
Body: []byte("abc"),
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(string(resp.Body)).To(Equal("got:abc"))
|
||||
})
|
||||
|
||||
It("should handle PUT requests with body", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
_, _ = w.Write([]byte("put:" + string(b)))
|
||||
}))
|
||||
resp, err := svc.Put(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
Body: []byte("xyz"),
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(string(resp.Body)).To(Equal("put:xyz"))
|
||||
})
|
||||
|
||||
It("should handle DELETE requests", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}))
|
||||
resp, err := svc.Delete(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(resp.Status).To(Equal(int32(204)))
|
||||
})
|
||||
|
||||
It("should handle PATCH requests with body", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(b)
|
||||
_, _ = w.Write([]byte("patch:" + string(b)))
|
||||
}))
|
||||
resp, err := svc.Patch(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
Body: []byte("test-patch"),
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(string(resp.Body)).To(Equal("patch:test-patch"))
|
||||
})
|
||||
|
||||
It("should handle HEAD requests", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Length", "42")
|
||||
w.WriteHeader(200)
|
||||
// HEAD responses shouldn't have a body, but the headers should be present
|
||||
}))
|
||||
resp, err := svc.Head(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(resp.Status).To(Equal(int32(200)))
|
||||
Expect(resp.Headers["Content-Type"]).To(Equal("application/json"))
|
||||
Expect(resp.Headers["Content-Length"]).To(Equal("42"))
|
||||
Expect(resp.Body).To(BeEmpty()) // HEAD responses have no body
|
||||
})
|
||||
|
||||
It("should handle OPTIONS requests", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Allow", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
resp, err := svc.Options(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(resp.Status).To(Equal(int32(200)))
|
||||
Expect(resp.Headers["Allow"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"))
|
||||
Expect(resp.Headers["Access-Control-Allow-Methods"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"))
|
||||
})
|
||||
|
||||
It("should handle timeouts and errors", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}))
|
||||
resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
TimeoutMs: 1,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp).NotTo(BeNil())
|
||||
Expect(resp.Error).To(ContainSubstring("deadline exceeded"))
|
||||
})
|
||||
|
||||
It("should return error on context timeout", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||
defer cancel()
|
||||
resp, err := svc.Get(ctx, &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp).NotTo(BeNil())
|
||||
Expect(resp.Error).To(ContainSubstring("context deadline exceeded"))
|
||||
})
|
||||
|
||||
It("should return error on context cancellation", func() {
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
resp, err := svc.Get(ctx, &hosthttp.HttpRequest{
|
||||
Url: ts.URL,
|
||||
TimeoutMs: 1000,
|
||||
})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(resp).NotTo(BeNil())
|
||||
Expect(resp.Error).To(ContainSubstring("context canceled"))
|
||||
})
|
||||
})
|
||||
192
plugins/host_network_permissions_base.go
Normal file
192
plugins/host_network_permissions_base.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NetworkPermissionsBase contains common functionality for network-based permissions
|
||||
type networkPermissionsBase struct {
|
||||
Reason string `json:"reason"`
|
||||
AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty"`
|
||||
}
|
||||
|
||||
// URLMatcher provides URL pattern matching functionality
|
||||
type urlMatcher struct{}
|
||||
|
||||
// newURLMatcher creates a new URL matcher instance
|
||||
func newURLMatcher() *urlMatcher {
|
||||
return &urlMatcher{}
|
||||
}
|
||||
|
||||
// checkURLPolicy performs common checks for a URL against network policies.
|
||||
func checkURLPolicy(requestURL string, allowLocalNetwork bool) (*url.URL, error) {
|
||||
parsedURL, err := url.Parse(requestURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Check local network restrictions
|
||||
if !allowLocalNetwork {
|
||||
if err := checkLocalNetwork(parsedURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return parsedURL, nil
|
||||
}
|
||||
|
||||
// MatchesURLPattern checks if a URL matches a given pattern
|
||||
func (m *urlMatcher) MatchesURLPattern(requestURL, pattern string) bool {
|
||||
// Handle wildcard pattern
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Parse both URLs to handle path matching correctly
|
||||
reqURL, err := url.Parse(requestURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
patternURL, err := url.Parse(pattern)
|
||||
if err != nil {
|
||||
// If pattern is not a valid URL, treat it as a simple string pattern
|
||||
regexPattern := m.urlPatternToRegex(pattern)
|
||||
matched, err := regexp.MatchString(regexPattern, requestURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// Match scheme
|
||||
if patternURL.Scheme != "" && patternURL.Scheme != reqURL.Scheme {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match host with wildcard support
|
||||
if !m.matchesHost(reqURL.Host, patternURL.Host) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match path with wildcard support
|
||||
// Special case: if pattern URL has empty path and contains wildcards, allow any path (domain-only wildcard matching)
|
||||
if (patternURL.Path == "" || patternURL.Path == "/") && strings.Contains(pattern, "*") {
|
||||
// This is a domain-only wildcard pattern, allow any path
|
||||
return true
|
||||
}
|
||||
if !m.matchesPath(reqURL.Path, patternURL.Path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// urlPatternToRegex converts a URL pattern with wildcards to a regex pattern
|
||||
func (m *urlMatcher) urlPatternToRegex(pattern string) string {
|
||||
// Escape special regex characters except *
|
||||
escaped := regexp.QuoteMeta(pattern)
|
||||
|
||||
// Replace escaped \* with regex pattern for wildcard matching
|
||||
// For subdomain: *.example.com -> [^.]*\.example\.com
|
||||
// For path: /api/* -> /api/.*
|
||||
escaped = strings.ReplaceAll(escaped, "\\*", ".*")
|
||||
|
||||
// Anchor the pattern to match the full URL
|
||||
return "^" + escaped + "$"
|
||||
}
|
||||
|
||||
// matchesHost checks if a host matches a pattern with wildcard support
|
||||
func (m *urlMatcher) matchesHost(host, pattern string) bool {
|
||||
if pattern == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle wildcard patterns anywhere in the host
|
||||
if strings.Contains(pattern, "*") {
|
||||
patterns := []string{
|
||||
strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[0-9.]+"), // IP pattern
|
||||
strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[^.]*"), // Domain pattern
|
||||
}
|
||||
|
||||
for _, regexPattern := range patterns {
|
||||
fullPattern := "^" + regexPattern + "$"
|
||||
if matched, err := regexp.MatchString(fullPattern, host); err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return host == pattern
|
||||
}
|
||||
|
||||
// matchesPath checks if a path matches a pattern with wildcard support
|
||||
func (m *urlMatcher) matchesPath(path, pattern string) bool {
|
||||
// Normalize empty paths to "/"
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if pattern == "" {
|
||||
pattern = "/"
|
||||
}
|
||||
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle wildcard paths
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := pattern[:len(pattern)-2] // Remove "/*"
|
||||
if prefix == "" {
|
||||
prefix = "/"
|
||||
}
|
||||
return strings.HasPrefix(path, prefix)
|
||||
}
|
||||
|
||||
return path == pattern
|
||||
}
|
||||
|
||||
// CheckLocalNetwork checks if the URL is accessing local network resources
|
||||
func checkLocalNetwork(parsedURL *url.URL) error {
|
||||
host := parsedURL.Hostname()
|
||||
|
||||
// Check for localhost variants
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return fmt.Errorf("requests to localhost are not allowed")
|
||||
}
|
||||
|
||||
// Try to parse as IP address
|
||||
ip := net.ParseIP(host)
|
||||
if ip != nil && isPrivateIP(ip) {
|
||||
return fmt.Errorf("requests to private IP addresses are not allowed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsPrivateIP checks if an IP is loopback, private, or link-local (IPv4/IPv6).
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
// IPv4 link-local: 169.254.0.0/16
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
return ip4[0] == 169 && ip4[1] == 254
|
||||
}
|
||||
// IPv6 link-local: fe80::/10
|
||||
if ip16 := ip.To16(); ip16 != nil && ip.To4() == nil {
|
||||
return ip16[0] == 0xfe && (ip16[1]&0xc0) == 0x80
|
||||
}
|
||||
return false
|
||||
}
|
||||
119
plugins/host_network_permissions_base_test.go
Normal file
119
plugins/host_network_permissions_base_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("networkPermissionsBase", func() {
|
||||
Describe("urlMatcher", func() {
|
||||
var matcher *urlMatcher
|
||||
|
||||
BeforeEach(func() {
|
||||
matcher = newURLMatcher()
|
||||
})
|
||||
|
||||
Describe("MatchesURLPattern", func() {
|
||||
DescribeTable("exact URL matching",
|
||||
func(requestURL, pattern string, expected bool) {
|
||||
result := matcher.MatchesURLPattern(requestURL, pattern)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
Entry("exact match", "https://api.example.com", "https://api.example.com", true),
|
||||
Entry("different domain", "https://api.example.com", "https://api.other.com", false),
|
||||
Entry("different scheme", "http://api.example.com", "https://api.example.com", false),
|
||||
Entry("different path", "https://api.example.com/v1", "https://api.example.com/v2", false),
|
||||
)
|
||||
|
||||
DescribeTable("wildcard pattern matching",
|
||||
func(requestURL, pattern string, expected bool) {
|
||||
result := matcher.MatchesURLPattern(requestURL, pattern)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
Entry("universal wildcard", "https://api.example.com", "*", true),
|
||||
Entry("subdomain wildcard match", "https://api.example.com", "https://*.example.com", true),
|
||||
Entry("subdomain wildcard non-match", "https://api.other.com", "https://*.example.com", false),
|
||||
Entry("path wildcard match", "https://api.example.com/v1/users", "https://api.example.com/*", true),
|
||||
Entry("path wildcard non-match", "https://other.example.com/v1", "https://api.example.com/*", false),
|
||||
Entry("port wildcard match", "https://api.example.com:8080", "https://api.example.com:*", true),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("isPrivateIP", func() {
|
||||
DescribeTable("IPv4 private IP detection",
|
||||
func(ip string, expected bool) {
|
||||
parsedIP := net.ParseIP(ip)
|
||||
Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip)
|
||||
result := isPrivateIP(parsedIP)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
// Private IPv4 ranges
|
||||
Entry("10.0.0.1 (10.0.0.0/8)", "10.0.0.1", true),
|
||||
Entry("10.255.255.255 (10.0.0.0/8)", "10.255.255.255", true),
|
||||
Entry("172.16.0.1 (172.16.0.0/12)", "172.16.0.1", true),
|
||||
Entry("172.31.255.255 (172.16.0.0/12)", "172.31.255.255", true),
|
||||
Entry("192.168.1.1 (192.168.0.0/16)", "192.168.1.1", true),
|
||||
Entry("192.168.255.255 (192.168.0.0/16)", "192.168.255.255", true),
|
||||
Entry("127.0.0.1 (localhost)", "127.0.0.1", true),
|
||||
Entry("127.255.255.255 (localhost)", "127.255.255.255", true),
|
||||
Entry("169.254.1.1 (link-local)", "169.254.1.1", true),
|
||||
Entry("169.254.255.255 (link-local)", "169.254.255.255", true),
|
||||
|
||||
// Public IPv4 addresses
|
||||
Entry("8.8.8.8 (Google DNS)", "8.8.8.8", false),
|
||||
Entry("1.1.1.1 (Cloudflare DNS)", "1.1.1.1", false),
|
||||
Entry("208.67.222.222 (OpenDNS)", "208.67.222.222", false),
|
||||
Entry("172.15.255.255 (just outside 172.16.0.0/12)", "172.15.255.255", false),
|
||||
Entry("172.32.0.1 (just outside 172.16.0.0/12)", "172.32.0.1", false),
|
||||
)
|
||||
|
||||
DescribeTable("IPv6 private IP detection",
|
||||
func(ip string, expected bool) {
|
||||
parsedIP := net.ParseIP(ip)
|
||||
Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip)
|
||||
result := isPrivateIP(parsedIP)
|
||||
Expect(result).To(Equal(expected))
|
||||
},
|
||||
// Private IPv6 ranges
|
||||
Entry("::1 (IPv6 localhost)", "::1", true),
|
||||
Entry("fe80::1 (link-local)", "fe80::1", true),
|
||||
Entry("fc00::1 (unique local)", "fc00::1", true),
|
||||
Entry("fd00::1 (unique local)", "fd00::1", true),
|
||||
|
||||
// Public IPv6 addresses
|
||||
Entry("2001:4860:4860::8888 (Google DNS)", "2001:4860:4860::8888", false),
|
||||
Entry("2606:4700:4700::1111 (Cloudflare DNS)", "2606:4700:4700::1111", false),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("checkLocalNetwork", func() {
|
||||
DescribeTable("local network detection",
|
||||
func(urlStr string, shouldError bool, expectedErrorSubstring string) {
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = checkLocalNetwork(parsedURL)
|
||||
if shouldError {
|
||||
Expect(err).To(HaveOccurred())
|
||||
if expectedErrorSubstring != "" {
|
||||
Expect(err.Error()).To(ContainSubstring(expectedErrorSubstring))
|
||||
}
|
||||
} else {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
},
|
||||
Entry("localhost", "http://localhost:8080", true, "localhost"),
|
||||
Entry("127.0.0.1", "http://127.0.0.1:3000", true, "localhost"),
|
||||
Entry("::1", "http://[::1]:8080", true, "localhost"),
|
||||
Entry("private IP 192.168.1.100", "http://192.168.1.100", true, "private IP"),
|
||||
Entry("private IP 10.0.0.1", "http://10.0.0.1", true, "private IP"),
|
||||
Entry("private IP 172.16.0.1", "http://172.16.0.1", true, "private IP"),
|
||||
Entry("public IP 8.8.8.8", "http://8.8.8.8", false, ""),
|
||||
Entry("public domain", "https://api.example.com", false, ""),
|
||||
)
|
||||
})
|
||||
})
|
||||
338
plugins/host_scheduler.go
Normal file
338
plugins/host_scheduler.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
navidsched "github.com/navidrome/navidrome/scheduler"
|
||||
)
|
||||
|
||||
const (
|
||||
ScheduleTypeOneTime = "one-time"
|
||||
ScheduleTypeRecurring = "recurring"
|
||||
)
|
||||
|
||||
// ScheduledCallback represents a registered schedule callback
|
||||
type ScheduledCallback struct {
|
||||
ID string
|
||||
PluginID string
|
||||
Type string // "one-time" or "recurring"
|
||||
Payload []byte
|
||||
EntryID int // Used for recurring schedules via the scheduler
|
||||
Cancel context.CancelFunc // Used for one-time schedules
|
||||
}
|
||||
|
||||
// SchedulerHostFunctions implements the scheduler.SchedulerService interface
|
||||
type SchedulerHostFunctions struct {
|
||||
ss *schedulerService
|
||||
pluginID string
|
||||
}
|
||||
|
||||
func (s SchedulerHostFunctions) ScheduleOneTime(ctx context.Context, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
|
||||
return s.ss.scheduleOneTime(ctx, s.pluginID, req)
|
||||
}
|
||||
|
||||
func (s SchedulerHostFunctions) ScheduleRecurring(ctx context.Context, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
|
||||
return s.ss.scheduleRecurring(ctx, s.pluginID, req)
|
||||
}
|
||||
|
||||
func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
|
||||
return s.ss.cancelSchedule(ctx, s.pluginID, req)
|
||||
}
|
||||
|
||||
func (s SchedulerHostFunctions) TimeNow(ctx context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
|
||||
return s.ss.timeNow(ctx, req)
|
||||
}
|
||||
|
||||
type schedulerService struct {
|
||||
// Map of schedule IDs to their callback info
|
||||
schedules map[string]*ScheduledCallback
|
||||
manager *managerImpl
|
||||
navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newSchedulerService creates a new schedulerService instance
|
||||
func newSchedulerService(manager *managerImpl) *schedulerService {
|
||||
return &schedulerService{
|
||||
schedules: make(map[string]*ScheduledCallback),
|
||||
manager: manager,
|
||||
navidSched: navidsched.GetInstance(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *schedulerService) HostFunctions(pluginID string) SchedulerHostFunctions {
|
||||
return SchedulerHostFunctions{
|
||||
ss: s,
|
||||
pluginID: pluginID,
|
||||
}
|
||||
}
|
||||
|
||||
// Safe accessor methods for tests
|
||||
|
||||
// hasSchedule safely checks if a schedule exists
|
||||
func (s *schedulerService) hasSchedule(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, exists := s.schedules[id]
|
||||
return exists
|
||||
}
|
||||
|
||||
// scheduleCount safely returns the number of schedules
|
||||
func (s *schedulerService) scheduleCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.schedules)
|
||||
}
|
||||
|
||||
// getScheduleType safely returns the type of a schedule
|
||||
func (s *schedulerService) getScheduleType(id string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cb, exists := s.schedules[id]; exists {
|
||||
return cb.Type
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scheduleJob is a helper function that handles the common logic for scheduling jobs
|
||||
func (s *schedulerService) scheduleJob(pluginID string, scheduleId string, jobType string, payload []byte) (string, *ScheduledCallback, context.CancelFunc, error) {
|
||||
if s.manager == nil {
|
||||
return "", nil, nil, fmt.Errorf("scheduler service not properly initialized")
|
||||
}
|
||||
|
||||
// Original scheduleId (what the plugin will see)
|
||||
originalScheduleId := scheduleId
|
||||
if originalScheduleId == "" {
|
||||
// Generate a random ID if one wasn't provided
|
||||
originalScheduleId, _ = gonanoid.New(10)
|
||||
}
|
||||
|
||||
// Internal scheduleId (prefixed with plugin name to avoid conflicts)
|
||||
internalScheduleId := pluginID + ":" + originalScheduleId
|
||||
|
||||
// Store any existing cancellation function to call after we've updated the map
|
||||
var cancelExisting context.CancelFunc
|
||||
|
||||
// Check if there's an existing schedule with the same ID, we'll cancel it after updating the map
|
||||
if existingSchedule, ok := s.schedules[internalScheduleId]; ok {
|
||||
log.Debug("Replacing existing schedule with same ID", "plugin", pluginID, "scheduleID", originalScheduleId)
|
||||
|
||||
// Store cancel information but don't call it yet
|
||||
if existingSchedule.Type == ScheduleTypeOneTime && existingSchedule.Cancel != nil {
|
||||
// We'll set the Cancel to nil to prevent the old job from removing the new one
|
||||
cancelExisting = existingSchedule.Cancel
|
||||
existingSchedule.Cancel = nil
|
||||
} else if existingSchedule.Type == ScheduleTypeRecurring {
|
||||
existingRecurringEntryID := existingSchedule.EntryID
|
||||
if existingRecurringEntryID != 0 {
|
||||
s.navidSched.Remove(existingRecurringEntryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the callback object
|
||||
callback := &ScheduledCallback{
|
||||
ID: originalScheduleId,
|
||||
PluginID: pluginID,
|
||||
Type: jobType,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
return internalScheduleId, callback, cancelExisting, nil
|
||||
}
|
||||
|
||||
// scheduleOneTime registers a new one-time scheduled job
|
||||
func (s *schedulerService) scheduleOneTime(_ context.Context, pluginID string, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeOneTime, req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create a context with cancel for this one-time schedule
|
||||
scheduleCtx, cancel := context.WithCancel(context.Background())
|
||||
callback.Cancel = cancel
|
||||
|
||||
// Store the callback info
|
||||
s.schedules[internalScheduleId] = callback
|
||||
|
||||
// Now that the new job is in the map, we can safely cancel the old one
|
||||
if cancelExisting != nil {
|
||||
// Cancel in a goroutine to avoid deadlock since we're already holding the lock
|
||||
go cancelExisting()
|
||||
}
|
||||
|
||||
log.Debug("One-time schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId)
|
||||
|
||||
// Start the timer goroutine with the internal ID
|
||||
go s.runOneTimeSchedule(scheduleCtx, internalScheduleId, time.Duration(req.DelaySeconds)*time.Second)
|
||||
|
||||
// Return the original ID to the plugin
|
||||
return &scheduler.ScheduleResponse{
|
||||
ScheduleId: callback.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// scheduleRecurring registers a new recurring scheduled job
|
||||
func (s *schedulerService) scheduleRecurring(_ context.Context, pluginID string, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeRecurring, req.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Schedule the job with the Navidrome scheduler
|
||||
entryID, err := s.navidSched.Add(req.CronExpression, func() {
|
||||
s.executeCallback(context.Background(), internalScheduleId, true)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to schedule recurring job: %w", err)
|
||||
}
|
||||
|
||||
// Store the entry ID so we can cancel it later
|
||||
callback.EntryID = entryID
|
||||
|
||||
// Store the callback info
|
||||
s.schedules[internalScheduleId] = callback
|
||||
|
||||
// Now that the new job is in the map, we can safely cancel the old one
|
||||
if cancelExisting != nil {
|
||||
// Cancel in a goroutine to avoid deadlock since we're already holding the lock
|
||||
go cancelExisting()
|
||||
}
|
||||
|
||||
log.Debug("Recurring schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId, "cron", req.CronExpression)
|
||||
|
||||
// Return the original ID to the plugin
|
||||
return &scheduler.ScheduleResponse{
|
||||
ScheduleId: callback.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cancelSchedule cancels a scheduled job (either one-time or recurring)
|
||||
func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
internalScheduleId := pluginID + ":" + req.ScheduleId
|
||||
callback, exists := s.schedules[internalScheduleId]
|
||||
if !exists {
|
||||
return &scheduler.CancelResponse{
|
||||
Success: false,
|
||||
Error: "schedule not found",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Store the cancel functions to call after we've updated the schedule map
|
||||
var cancelFunc context.CancelFunc
|
||||
var recurringEntryID int
|
||||
|
||||
// Store cancel information but don't call it yet
|
||||
if callback.Type == ScheduleTypeOneTime && callback.Cancel != nil {
|
||||
cancelFunc = callback.Cancel
|
||||
callback.Cancel = nil // Set to nil to prevent the cancel handler from removing the job
|
||||
} else if callback.Type == ScheduleTypeRecurring {
|
||||
recurringEntryID = callback.EntryID
|
||||
}
|
||||
|
||||
// First remove from the map
|
||||
delete(s.schedules, internalScheduleId)
|
||||
|
||||
// Now perform the cancellation safely
|
||||
if cancelFunc != nil {
|
||||
// Execute in a goroutine to avoid deadlock since we're already holding the lock
|
||||
go cancelFunc()
|
||||
}
|
||||
if recurringEntryID != 0 {
|
||||
s.navidSched.Remove(recurringEntryID)
|
||||
}
|
||||
|
||||
log.Debug("Schedule canceled", "plugin", pluginID, "scheduleID", req.ScheduleId, "internalID", internalScheduleId, "type", callback.Type)
|
||||
|
||||
return &scheduler.CancelResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// timeNow returns the current time in multiple formats
|
||||
func (s *schedulerService) timeNow(_ context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
|
||||
now := time.Now()
|
||||
|
||||
return &scheduler.TimeNowResponse{
|
||||
Rfc3339Nano: now.Format(time.RFC3339Nano),
|
||||
UnixMilli: now.UnixMilli(),
|
||||
LocalTimeZone: now.Location().String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runOneTimeSchedule handles the one-time schedule execution and callback
|
||||
func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) {
|
||||
tmr := time.NewTimer(delay)
|
||||
defer tmr.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Schedule was cancelled via its context
|
||||
// We're no longer removing the schedule here because that's handled by the code that
|
||||
// cancelled the context
|
||||
log.Debug("One-time schedule context canceled", "internalID", internalScheduleId)
|
||||
return
|
||||
|
||||
case <-tmr.C:
|
||||
// Timer fired, execute the callback
|
||||
s.executeCallback(ctx, internalScheduleId, false)
|
||||
}
|
||||
}
|
||||
|
||||
// executeCallback calls the plugin's OnSchedulerCallback method
|
||||
func (s *schedulerService) executeCallback(ctx context.Context, internalScheduleId string, isRecurring bool) {
|
||||
s.mu.Lock()
|
||||
callback := s.schedules[internalScheduleId]
|
||||
// Only remove one-time schedules from the map after execution
|
||||
if callback != nil && callback.Type == ScheduleTypeOneTime {
|
||||
delete(s.schedules, internalScheduleId)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if callback == nil {
|
||||
log.Error("Schedule not found for callback", "internalID", internalScheduleId)
|
||||
return
|
||||
}
|
||||
|
||||
ctx = log.NewContext(ctx, "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callback.Type)
|
||||
log.Debug("Executing schedule callback")
|
||||
start := time.Now()
|
||||
|
||||
// Get the plugin
|
||||
p := s.manager.LoadPlugin(callback.PluginID, CapabilitySchedulerCallback)
|
||||
if p == nil {
|
||||
log.Error("Plugin not found for callback", "plugin", callback.PluginID)
|
||||
return
|
||||
}
|
||||
|
||||
// Type-check the plugin
|
||||
plugin, ok := p.(*wasmSchedulerCallback)
|
||||
if !ok {
|
||||
log.Error("Plugin does not implement SchedulerCallback", "plugin", callback.PluginID)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the plugin's OnSchedulerCallback method
|
||||
log.Trace(ctx, "Executing schedule callback")
|
||||
err := plugin.OnSchedulerCallback(ctx, callback.ID, callback.Payload, isRecurring)
|
||||
if err != nil {
|
||||
log.Error("Error executing schedule callback", "elapsed", time.Since(start), err)
|
||||
return
|
||||
}
|
||||
log.Debug("Schedule callback executed", "elapsed", time.Since(start))
|
||||
}
|
||||
192
plugins/host_scheduler_test.go
Normal file
192
plugins/host_scheduler_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SchedulerService", func() {
|
||||
var (
|
||||
ss *schedulerService
|
||||
manager *managerImpl
|
||||
pluginName = "test_plugin"
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
manager = createManager(nil, metrics.NewNoopInstance())
|
||||
ss = manager.schedulerService
|
||||
})
|
||||
|
||||
Describe("One-time scheduling", func() {
|
||||
It("schedules one-time jobs successfully", func() {
|
||||
req := &scheduler.ScheduleOneTimeRequest{
|
||||
DelaySeconds: 1,
|
||||
Payload: []byte("test payload"),
|
||||
ScheduleId: "test-job",
|
||||
}
|
||||
|
||||
resp, err := ss.scheduleOneTime(context.Background(), pluginName, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.ScheduleId).To(Equal("test-job"))
|
||||
Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeTrue())
|
||||
Expect(ss.getScheduleType(pluginName + ":" + "test-job")).To(Equal(ScheduleTypeOneTime))
|
||||
|
||||
// Test auto-generated ID
|
||||
req.ScheduleId = ""
|
||||
resp, err = ss.scheduleOneTime(context.Background(), pluginName, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.ScheduleId).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("cancels one-time jobs successfully", func() {
|
||||
req := &scheduler.ScheduleOneTimeRequest{
|
||||
DelaySeconds: 10,
|
||||
ScheduleId: "test-job",
|
||||
}
|
||||
|
||||
_, err := ss.scheduleOneTime(context.Background(), pluginName, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cancelReq := &scheduler.CancelRequest{
|
||||
ScheduleId: "test-job",
|
||||
}
|
||||
|
||||
resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Success).To(BeTrue())
|
||||
Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Recurring scheduling", func() {
|
||||
It("schedules recurring jobs successfully", func() {
|
||||
req := &scheduler.ScheduleRecurringRequest{
|
||||
CronExpression: "* * * * *", // Every minute
|
||||
Payload: []byte("test payload"),
|
||||
ScheduleId: "test-cron",
|
||||
}
|
||||
|
||||
resp, err := ss.scheduleRecurring(context.Background(), pluginName, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.ScheduleId).To(Equal("test-cron"))
|
||||
Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeTrue())
|
||||
Expect(ss.getScheduleType(pluginName + ":" + "test-cron")).To(Equal(ScheduleTypeRecurring))
|
||||
|
||||
// Test auto-generated ID
|
||||
req.ScheduleId = ""
|
||||
resp, err = ss.scheduleRecurring(context.Background(), pluginName, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.ScheduleId).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("cancels recurring jobs successfully", func() {
|
||||
req := &scheduler.ScheduleRecurringRequest{
|
||||
CronExpression: "* * * * *", // Every minute
|
||||
ScheduleId: "test-cron",
|
||||
}
|
||||
|
||||
_, err := ss.scheduleRecurring(context.Background(), pluginName, req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
cancelReq := &scheduler.CancelRequest{
|
||||
ScheduleId: "test-cron",
|
||||
}
|
||||
|
||||
resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Success).To(BeTrue())
|
||||
Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Replace existing schedules", func() {
|
||||
It("replaces one-time jobs with new ones", func() {
|
||||
// Create first job
|
||||
req1 := &scheduler.ScheduleOneTimeRequest{
|
||||
DelaySeconds: 10,
|
||||
Payload: []byte("test payload 1"),
|
||||
ScheduleId: "replace-job",
|
||||
}
|
||||
_, err := ss.scheduleOneTime(context.Background(), pluginName, req1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify that the initial job exists
|
||||
scheduleId := pluginName + ":" + "replace-job"
|
||||
Expect(ss.hasSchedule(scheduleId)).To(BeTrue(), "Initial schedule should exist")
|
||||
|
||||
beforeCount := ss.scheduleCount()
|
||||
|
||||
// Replace with second job using same ID
|
||||
req2 := &scheduler.ScheduleOneTimeRequest{
|
||||
DelaySeconds: 60, // Use a longer delay to ensure it doesn't execute during the test
|
||||
Payload: []byte("test payload 2"),
|
||||
ScheduleId: "replace-job",
|
||||
}
|
||||
|
||||
_, err = ss.scheduleOneTime(context.Background(), pluginName, req2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() bool {
|
||||
return ss.hasSchedule(scheduleId)
|
||||
}).Should(BeTrue(), "Schedule should exist after replacement")
|
||||
Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement")
|
||||
})
|
||||
|
||||
It("replaces recurring jobs with new ones", func() {
|
||||
// Create first job
|
||||
req1 := &scheduler.ScheduleRecurringRequest{
|
||||
CronExpression: "0 * * * *",
|
||||
Payload: []byte("test payload 1"),
|
||||
ScheduleId: "replace-cron",
|
||||
}
|
||||
_, err := ss.scheduleRecurring(context.Background(), pluginName, req1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
beforeCount := ss.scheduleCount()
|
||||
|
||||
// Replace with second job using same ID
|
||||
req2 := &scheduler.ScheduleRecurringRequest{
|
||||
CronExpression: "*/5 * * * *",
|
||||
Payload: []byte("test payload 2"),
|
||||
ScheduleId: "replace-cron",
|
||||
}
|
||||
|
||||
_, err = ss.scheduleRecurring(context.Background(), pluginName, req2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Eventually(func() bool {
|
||||
return ss.hasSchedule(pluginName + ":" + "replace-cron")
|
||||
}).Should(BeTrue(), "Schedule should exist after replacement")
|
||||
Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement")
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TimeNow", func() {
|
||||
It("returns current time in RFC3339Nano, Unix milliseconds, and local timezone", func() {
|
||||
now := time.Now()
|
||||
req := &scheduler.TimeNowRequest{}
|
||||
resp, err := ss.timeNow(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.UnixMilli).To(BeNumerically(">=", now.UnixMilli()))
|
||||
Expect(resp.LocalTimeZone).ToNot(BeEmpty())
|
||||
|
||||
// Validate RFC3339Nano format can be parsed
|
||||
parsedTime, parseErr := time.Parse(time.RFC3339Nano, resp.Rfc3339Nano)
|
||||
Expect(parseErr).ToNot(HaveOccurred())
|
||||
|
||||
// Validate that Unix milliseconds is reasonably close to the RFC3339Nano time
|
||||
expectedMillis := parsedTime.UnixMilli()
|
||||
Expect(resp.UnixMilli).To(Equal(expectedMillis))
|
||||
|
||||
// Validate local timezone matches the current system timezone
|
||||
expectedTimezone := now.Location().String()
|
||||
Expect(resp.LocalTimeZone).To(Equal(expectedTimezone))
|
||||
})
|
||||
})
|
||||
})
|
||||
170
plugins/host_subsonicapi.go
Normal file
170
plugins/host_subsonicapi.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/server/subsonic"
|
||||
)
|
||||
|
||||
// SubsonicAPIService is the interface for the Subsonic API service
|
||||
//
|
||||
// Authentication: The plugin must provide valid authentication parameters in the URL:
|
||||
// - Required: `u` (username) - The service validates this parameter is present
|
||||
// - Example: `"/rest/ping?u=admin"`
|
||||
//
|
||||
// URL Format: Only the path and query parameters from the URL are used - host, protocol, and method are ignored
|
||||
//
|
||||
// Automatic Parameters: The service automatically adds:
|
||||
// - `c`: Plugin name (client identifier)
|
||||
// - `v`: Subsonic API version (1.16.1)
|
||||
// - `f`: Response format (json)
|
||||
//
|
||||
// See example usage in the `plugins/examples/subsonicapi-demo` plugin
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
ds model.DataStore
|
||||
permissions *subsonicAPIPermissions
|
||||
}
|
||||
|
||||
func newSubsonicAPIService(pluginID string, router *SubsonicRouter, ds model.DataStore, permissions *schema.PluginManifestPermissionsSubsonicapi) subsonicapi.SubsonicAPIService {
|
||||
return &subsonicAPIServiceImpl{
|
||||
pluginID: pluginID,
|
||||
router: *router,
|
||||
ds: ds,
|
||||
permissions: parseSubsonicAPIPermissions(permissions),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.CallRequest) (*subsonicapi.CallResponse, error) {
|
||||
if s.router == nil {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: "SubsonicAPI router not available",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Parse the input URL
|
||||
parsedURL, err := url.Parse(req.Url)
|
||||
if err != nil {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: fmt.Sprintf("invalid URL format: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
query := parsedURL.Query()
|
||||
|
||||
// Validate that 'u' (username) parameter is present
|
||||
username := query.Get("u")
|
||||
if username == "" {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: "missing required parameter 'u' (username)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.checkPermissions(ctx, username); err != nil {
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
||||
return &subsonicapi.CallResponse{Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("f", "json") // Response format
|
||||
query.Set("v", subsonic.Version) // API version
|
||||
|
||||
// Extract the endpoint from the path
|
||||
endpoint := path.Base(parsedURL.Path)
|
||||
|
||||
// Build the final URL with processed path and modified query parameters
|
||||
finalURL := &url.URL{
|
||||
Path: "/" + endpoint,
|
||||
RawQuery: query.Encode(),
|
||||
}
|
||||
|
||||
// Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
|
||||
// Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
|
||||
// SubsonicAPI call doesn't inherit routing information from the parent handler,
|
||||
// which would cause Chi to invoke the wrong handler. Authentication context is
|
||||
// explicitly added in the next step via request.WithInternalAuth.
|
||||
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
|
||||
if err != nil {
|
||||
return &subsonicapi.CallResponse{
|
||||
Error: fmt.Sprintf("failed to create HTTP request: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Set internal authentication context using the username from the 'u' parameter
|
||||
authCtx := request.WithInternalAuth(httpReq.Context(), username)
|
||||
httpReq = httpReq.WithContext(authCtx)
|
||||
|
||||
// Use ResponseRecorder to capture the response
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
// Call the subsonic router
|
||||
s.router.ServeHTTP(recorder, httpReq)
|
||||
|
||||
// Return the response body as JSON
|
||||
return &subsonicapi.CallResponse{
|
||||
Json: recorder.Body.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||
if s.permissions == nil {
|
||||
return nil
|
||||
}
|
||||
if len(s.permissions.AllowedUsernames) > 0 {
|
||||
if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok {
|
||||
return fmt.Errorf("username %s is not allowed", username)
|
||||
}
|
||||
}
|
||||
if !s.permissions.AllowAdmins {
|
||||
if s.router == nil {
|
||||
return fmt.Errorf("permissions check failed: router not available")
|
||||
}
|
||||
usr, err := s.ds.User(ctx).FindByUsername(username)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return fmt.Errorf("username %s not found", username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if usr.IsAdmin {
|
||||
return fmt.Errorf("calling SubsonicAPI as admin user is not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type subsonicAPIPermissions struct {
|
||||
AllowedUsernames []string
|
||||
AllowAdmins bool
|
||||
usernameMap map[string]struct{}
|
||||
}
|
||||
|
||||
func parseSubsonicAPIPermissions(data *schema.PluginManifestPermissionsSubsonicapi) *subsonicAPIPermissions {
|
||||
if data == nil {
|
||||
return &subsonicAPIPermissions{}
|
||||
}
|
||||
perms := &subsonicAPIPermissions{
|
||||
AllowedUsernames: data.AllowedUsernames,
|
||||
AllowAdmins: data.AllowAdmins,
|
||||
usernameMap: make(map[string]struct{}),
|
||||
}
|
||||
for _, u := range data.AllowedUsernames {
|
||||
perms.usernameMap[strings.ToLower(u)] = struct{}{}
|
||||
}
|
||||
return perms
|
||||
}
|
||||
218
plugins/host_subsonicapi_test.go
Normal file
218
plugins/host_subsonicapi_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/host/subsonicapi"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("SubsonicAPI Host Service", func() {
|
||||
var (
|
||||
service *subsonicAPIServiceImpl
|
||||
mockRouter http.Handler
|
||||
userRepo *tests.MockedUserRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Setup mock datastore with users
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
_ = userRepo.Put(&model.User{UserName: "admin", IsAdmin: true})
|
||||
_ = userRepo.Put(&model.User{UserName: "user", IsAdmin: false})
|
||||
ds := &tests.MockDataStore{MockedUser: userRepo}
|
||||
|
||||
// Create a mock router
|
||||
mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"subsonic-response":{"status":"ok","version":"1.16.1"}}`))
|
||||
})
|
||||
|
||||
// Create service implementation
|
||||
service = &subsonicAPIServiceImpl{
|
||||
pluginID: "test-plugin",
|
||||
router: mockRouter,
|
||||
ds: ds,
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to create a mock router that captures the request
|
||||
setupRequestCapture := func() **http.Request {
|
||||
var capturedRequest *http.Request
|
||||
mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedRequest = r
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
service.router = mockRouter
|
||||
return &capturedRequest
|
||||
}
|
||||
|
||||
Describe("Call", func() {
|
||||
Context("when subsonic router is available", func() {
|
||||
It("should process the request successfully", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
Expect(resp.Json).To(ContainSubstring("subsonic-response"))
|
||||
Expect(resp.Json).To(ContainSubstring("ok"))
|
||||
})
|
||||
|
||||
It("should add required parameters to the URL", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/getAlbum.view?id=123&u=admin",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
|
||||
query := (*capturedRequestPtr).URL.Query()
|
||||
Expect(query.Get("c")).To(Equal("test-plugin"))
|
||||
Expect(query.Get("f")).To(Equal("json"))
|
||||
Expect(query.Get("v")).To(Equal("1.16.1"))
|
||||
Expect(query.Get("id")).To(Equal("123"))
|
||||
Expect(query.Get("u")).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("should only use path and query from the input URL", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "https://external.example.com:8080/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping"))
|
||||
Expect((*capturedRequestPtr).URL.Host).To(BeEmpty())
|
||||
Expect((*capturedRequestPtr).URL.Scheme).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("ignores the path prefix in the URL", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/basepath/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping"))
|
||||
})
|
||||
|
||||
It("should set internal authentication with username from 'u' parameter", func() {
|
||||
capturedRequestPtr := setupRequestCapture()
|
||||
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=testuser",
|
||||
}
|
||||
|
||||
_, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*capturedRequestPtr).ToNot(BeNil())
|
||||
|
||||
// Verify that internal authentication is set in the context
|
||||
username, ok := request.InternalAuthFrom((*capturedRequestPtr).Context())
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(username).To(Equal("testuser"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when subsonic router is not available", func() {
|
||||
BeforeEach(func() {
|
||||
service.router = nil
|
||||
})
|
||||
|
||||
It("should return an error", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?u=admin",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(Equal("SubsonicAPI router not available"))
|
||||
Expect(resp.Json).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when URL is invalid", func() {
|
||||
It("should return an error for malformed URLs", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "://invalid-url",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(ContainSubstring("invalid URL format"))
|
||||
Expect(resp.Json).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return an error when 'u' parameter is missing", func() {
|
||||
req := &subsonicapi.CallRequest{
|
||||
Url: "/rest/ping?p=password",
|
||||
}
|
||||
|
||||
resp, err := service.Call(context.Background(), req)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).ToNot(BeNil())
|
||||
Expect(resp.Error).To(Equal("missing required parameter 'u' (username)"))
|
||||
Expect(resp.Json).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("permission checks", func() {
|
||||
It("rejects disallowed username", func() {
|
||||
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{
|
||||
Reason: "test",
|
||||
AllowedUsernames: []string{"user"},
|
||||
})
|
||||
|
||||
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Error).To(ContainSubstring("not allowed"))
|
||||
})
|
||||
|
||||
It("rejects admin when allowAdmins is false", func() {
|
||||
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test"})
|
||||
|
||||
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Error).To(ContainSubstring("not allowed"))
|
||||
})
|
||||
|
||||
It("allows admin when allowAdmins is true", func() {
|
||||
service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test", AllowAdmins: true})
|
||||
|
||||
resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Error).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
400
plugins/host_websocket.go
Normal file
400
plugins/host_websocket.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
)
|
||||
|
||||
// WebSocketConnection represents a WebSocket connection
|
||||
type WebSocketConnection struct {
|
||||
Conn *gorillaws.Conn
|
||||
PluginName string
|
||||
ConnectionID string
|
||||
Done chan struct{}
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// WebSocketHostFunctions implements the websocket.WebSocketService interface
|
||||
type WebSocketHostFunctions struct {
|
||||
ws *websocketService
|
||||
pluginID string
|
||||
permissions *webSocketPermissions
|
||||
}
|
||||
|
||||
func (s WebSocketHostFunctions) Connect(ctx context.Context, req *websocket.ConnectRequest) (*websocket.ConnectResponse, error) {
|
||||
return s.ws.connect(ctx, s.pluginID, req, s.permissions)
|
||||
}
|
||||
|
||||
func (s WebSocketHostFunctions) SendText(ctx context.Context, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) {
|
||||
return s.ws.sendText(ctx, s.pluginID, req)
|
||||
}
|
||||
|
||||
func (s WebSocketHostFunctions) SendBinary(ctx context.Context, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) {
|
||||
return s.ws.sendBinary(ctx, s.pluginID, req)
|
||||
}
|
||||
|
||||
func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseRequest) (*websocket.CloseResponse, error) {
|
||||
return s.ws.close(ctx, s.pluginID, req)
|
||||
}
|
||||
|
||||
// websocketService implements the WebSocket service functionality
|
||||
type websocketService struct {
|
||||
connections map[string]*WebSocketConnection
|
||||
manager *managerImpl
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// newWebsocketService creates a new websocketService instance
|
||||
func newWebsocketService(manager *managerImpl) *websocketService {
|
||||
return &websocketService{
|
||||
connections: make(map[string]*WebSocketConnection),
|
||||
manager: manager,
|
||||
}
|
||||
}
|
||||
|
||||
// HostFunctions returns the WebSocketHostFunctions for the given plugin
|
||||
func (s *websocketService) HostFunctions(pluginID string, permissions *webSocketPermissions) WebSocketHostFunctions {
|
||||
return WebSocketHostFunctions{
|
||||
ws: s,
|
||||
pluginID: pluginID,
|
||||
permissions: permissions,
|
||||
}
|
||||
}
|
||||
|
||||
// Safe accessor methods
|
||||
|
||||
// hasConnection safely checks if a connection exists
|
||||
func (s *websocketService) hasConnection(id string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, exists := s.connections[id]
|
||||
return exists
|
||||
}
|
||||
|
||||
// connectionCount safely returns the number of connections
|
||||
func (s *websocketService) connectionCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.connections)
|
||||
}
|
||||
|
||||
// getConnection safely retrieves a connection by internal ID
|
||||
func (s *websocketService) getConnection(internalConnectionID string) (*WebSocketConnection, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
conn, exists := s.connections[internalConnectionID]
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("connection not found")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// internalConnectionID builds the internal connection ID from plugin and connection ID
|
||||
func internalConnectionID(pluginName, connectionID string) string {
|
||||
return pluginName + ":" + connectionID
|
||||
}
|
||||
|
||||
// extractConnectionID extracts the original connection ID from an internal ID
|
||||
func extractConnectionID(internalID string) (string, error) {
|
||||
parts := strings.Split(internalID, ":")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid internal connection ID format: %s", internalID)
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
// connect establishes a new WebSocket connection
|
||||
func (s *websocketService) connect(ctx context.Context, pluginID string, req *websocket.ConnectRequest, permissions *webSocketPermissions) (*websocket.ConnectResponse, error) {
|
||||
if s.manager == nil {
|
||||
return nil, fmt.Errorf("websocket service not properly initialized")
|
||||
}
|
||||
|
||||
// Check permissions if they exist
|
||||
if permissions != nil {
|
||||
if err := permissions.IsConnectionAllowed(req.Url); err != nil {
|
||||
log.Warn(ctx, "WebSocket connection blocked by permissions", "plugin", pluginID, "url", req.Url, err)
|
||||
return &websocket.ConnectResponse{Error: "Connection blocked by plugin permissions: " + err.Error()}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create websocket dialer with the headers
|
||||
dialer := gorillaws.DefaultDialer
|
||||
header := make(map[string][]string)
|
||||
for k, v := range req.Headers {
|
||||
header[k] = []string{v}
|
||||
}
|
||||
|
||||
// Connect to the WebSocket server
|
||||
conn, resp, err := dialer.DialContext(ctx, req.Url, header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to WebSocket server: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Generate a connection ID
|
||||
if req.ConnectionId == "" {
|
||||
req.ConnectionId, _ = gonanoid.New(10)
|
||||
}
|
||||
connectionID := req.ConnectionId
|
||||
internal := internalConnectionID(pluginID, connectionID)
|
||||
|
||||
// Create the connection object
|
||||
wsConn := &WebSocketConnection{
|
||||
Conn: conn,
|
||||
PluginName: pluginID,
|
||||
ConnectionID: connectionID,
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Store the connection
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.connections[internal] = wsConn
|
||||
|
||||
log.Debug("WebSocket connection established", "plugin", pluginID, "connectionID", connectionID, "url", req.Url)
|
||||
|
||||
// Start the message handling goroutine
|
||||
go s.handleMessages(internal, wsConn)
|
||||
|
||||
return &websocket.ConnectResponse{
|
||||
ConnectionId: connectionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeMessage is a helper to send messages to a websocket connection
|
||||
func (s *websocketService) writeMessage(pluginID string, connID string, messageType int, data []byte) error {
|
||||
internal := internalConnectionID(pluginID, connID)
|
||||
|
||||
conn, err := s.getConnection(internal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
|
||||
if err := conn.Conn.WriteMessage(messageType, data); err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendText sends a text message over a WebSocket connection
|
||||
func (s *websocketService) sendText(ctx context.Context, pluginID string, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) {
|
||||
if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.TextMessage, []byte(req.Message)); err != nil {
|
||||
return &websocket.SendTextResponse{Error: err.Error()}, nil //nolint:nilerr
|
||||
}
|
||||
return &websocket.SendTextResponse{}, nil
|
||||
}
|
||||
|
||||
// sendBinary sends binary data over a WebSocket connection
|
||||
func (s *websocketService) sendBinary(ctx context.Context, pluginID string, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) {
|
||||
if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.BinaryMessage, req.Data); err != nil {
|
||||
return &websocket.SendBinaryResponse{Error: err.Error()}, nil //nolint:nilerr
|
||||
}
|
||||
return &websocket.SendBinaryResponse{}, nil
|
||||
}
|
||||
|
||||
// close closes a WebSocket connection
|
||||
func (s *websocketService) close(ctx context.Context, pluginID string, req *websocket.CloseRequest) (*websocket.CloseResponse, error) {
|
||||
internal := internalConnectionID(pluginID, req.ConnectionId)
|
||||
|
||||
s.mu.Lock()
|
||||
conn, exists := s.connections[internal]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
return &websocket.CloseResponse{Error: "connection not found"}, nil
|
||||
}
|
||||
delete(s.connections, internal)
|
||||
s.mu.Unlock()
|
||||
|
||||
// Signal the message handling goroutine to stop
|
||||
close(conn.Done)
|
||||
|
||||
// Close the connection with the specified code and reason
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
|
||||
err := conn.Conn.WriteControl(
|
||||
gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(int(req.Code), req.Reason),
|
||||
time.Now().Add(time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("Error sending close message", "plugin", pluginID, "error", err)
|
||||
}
|
||||
|
||||
if err := conn.Conn.Close(); err != nil {
|
||||
return nil, fmt.Errorf("error closing connection: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("WebSocket connection closed", "plugin", pluginID, "connectionID", req.ConnectionId)
|
||||
return &websocket.CloseResponse{}, nil
|
||||
}
|
||||
|
||||
// handleMessages processes incoming WebSocket messages
|
||||
func (s *websocketService) handleMessages(internalID string, conn *WebSocketConnection) {
|
||||
// Get the original connection ID (without plugin prefix)
|
||||
connectionID, err := extractConnectionID(internalID)
|
||||
if err != nil {
|
||||
log.Error("Invalid internal connection ID", "id", internalID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
defer func() {
|
||||
// Ensure the connection is removed from the map if not already removed
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.connections, internalID)
|
||||
|
||||
log.Debug("WebSocket message handler stopped", "plugin", conn.PluginName, "connectionID", connectionID)
|
||||
}()
|
||||
|
||||
// Add connection info to context
|
||||
ctx = log.NewContext(ctx,
|
||||
"connectionID", connectionID,
|
||||
"plugin", conn.PluginName,
|
||||
)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-conn.Done:
|
||||
// Connection was closed by a Close call
|
||||
return
|
||||
default:
|
||||
// Set a read deadline
|
||||
_ = conn.Conn.SetReadDeadline(time.Now().Add(time.Second * 60))
|
||||
|
||||
// Read the next message
|
||||
messageType, message, err := conn.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
s.notifyErrorCallback(ctx, connectionID, conn, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reset the read deadline
|
||||
_ = conn.Conn.SetReadDeadline(time.Time{})
|
||||
|
||||
// Process the message based on its type
|
||||
switch messageType {
|
||||
case gorillaws.TextMessage:
|
||||
s.notifyTextCallback(ctx, connectionID, conn, string(message))
|
||||
case gorillaws.BinaryMessage:
|
||||
s.notifyBinaryCallback(ctx, connectionID, conn, message)
|
||||
case gorillaws.CloseMessage:
|
||||
code := gorillaws.CloseNormalClosure
|
||||
reason := ""
|
||||
if len(message) >= 2 {
|
||||
code = int(binary.BigEndian.Uint16(message[:2]))
|
||||
if len(message) > 2 {
|
||||
reason = string(message[2:])
|
||||
}
|
||||
}
|
||||
s.notifyCloseCallback(ctx, connectionID, conn, code, reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeCallback is a common function that handles the plugin loading and execution
|
||||
// for all types of callbacks
|
||||
func (s *websocketService) executeCallback(ctx context.Context, pluginID, methodName string, fn func(context.Context, api.WebSocketCallback) error) {
|
||||
log.Debug(ctx, "WebSocket received")
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Get the plugin
|
||||
p := s.manager.LoadPlugin(pluginID, CapabilityWebSocketCallback)
|
||||
if p == nil {
|
||||
log.Error(ctx, "Plugin not found for WebSocket callback")
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = callMethod(ctx, p, methodName, func(inst api.WebSocketCallback) (struct{}, error) {
|
||||
// Call the appropriate callback function
|
||||
log.Trace(ctx, "Executing WebSocket callback")
|
||||
if err := fn(ctx, inst); err != nil {
|
||||
log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err)
|
||||
return struct{}{}, fmt.Errorf("error executing WebSocket callback: %w", err)
|
||||
}
|
||||
log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start))
|
||||
return struct{}{}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// notifyTextCallback notifies the plugin of a text message
|
||||
func (s *websocketService) notifyTextCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, message string) {
|
||||
req := &api.OnTextMessageRequest{
|
||||
ConnectionId: connectionID,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnTextMessage", "size", len(message))
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, "OnTextMessage", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnTextMessage(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// notifyBinaryCallback notifies the plugin of a binary message
|
||||
func (s *websocketService) notifyBinaryCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, data []byte) {
|
||||
req := &api.OnBinaryMessageRequest{
|
||||
ConnectionId: connectionID,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnBinaryMessage", "size", len(data))
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, "OnBinaryMessage", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnBinaryMessage(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// notifyErrorCallback notifies the plugin of an error
|
||||
func (s *websocketService) notifyErrorCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, errorMsg string) {
|
||||
req := &api.OnErrorRequest{
|
||||
ConnectionId: connectionID,
|
||||
Error: errorMsg,
|
||||
}
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnError", "error", errorMsg)
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, "OnError", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnError(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// notifyCloseCallback notifies the plugin that the connection was closed
|
||||
func (s *websocketService) notifyCloseCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, code int, reason string) {
|
||||
req := &api.OnCloseRequest{
|
||||
ConnectionId: connectionID,
|
||||
Code: int32(code),
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
ctx = log.NewContext(ctx, "callback", "OnClose", "code", code, "reason", reason)
|
||||
|
||||
s.executeCallback(ctx, conn.PluginName, "OnClose", func(ctx context.Context, plugin api.WebSocketCallback) error {
|
||||
_, err := checkErr(plugin.OnClose(ctx, req))
|
||||
return err
|
||||
})
|
||||
}
|
||||
76
plugins/host_websocket_permissions.go
Normal file
76
plugins/host_websocket_permissions.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
)
|
||||
|
||||
// WebSocketPermissions represents granular WebSocket access permissions for plugins
|
||||
type webSocketPermissions struct {
|
||||
*networkPermissionsBase
|
||||
AllowedUrls []string `json:"allowedUrls"`
|
||||
matcher *urlMatcher
|
||||
}
|
||||
|
||||
// parseWebSocketPermissions extracts WebSocket permissions from the schema
|
||||
func parseWebSocketPermissions(permData *schema.PluginManifestPermissionsWebsocket) (*webSocketPermissions, error) {
|
||||
if len(permData.AllowedUrls) == 0 {
|
||||
return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern")
|
||||
}
|
||||
|
||||
return &webSocketPermissions{
|
||||
networkPermissionsBase: &networkPermissionsBase{
|
||||
AllowLocalNetwork: permData.AllowLocalNetwork,
|
||||
},
|
||||
AllowedUrls: permData.AllowedUrls,
|
||||
matcher: newURLMatcher(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsConnectionAllowed checks if a WebSocket connection is allowed
|
||||
func (w *webSocketPermissions) IsConnectionAllowed(requestURL string) error {
|
||||
if _, err := checkURLPolicy(requestURL, w.AllowLocalNetwork); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// allowedUrls is required - no fallback to allow all URLs
|
||||
if len(w.AllowedUrls) == 0 {
|
||||
return fmt.Errorf("no allowed URLs configured for plugin")
|
||||
}
|
||||
|
||||
// Check URL patterns
|
||||
// First try exact matches, then wildcard matches
|
||||
|
||||
// Phase 1: Check for exact matches first
|
||||
for _, urlPattern := range w.AllowedUrls {
|
||||
if urlPattern == "*" || (!containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Check wildcard patterns
|
||||
for _, urlPattern := range w.AllowedUrls {
|
||||
if containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL)
|
||||
}
|
||||
|
||||
// containsWildcard checks if a URL pattern contains wildcard characters
|
||||
func containsWildcard(pattern string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for wildcards anywhere in the pattern
|
||||
for _, char := range pattern {
|
||||
if char == '*' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
79
plugins/host_websocket_permissions_test.go
Normal file
79
plugins/host_websocket_permissions_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("WebSocket Permissions", func() {
|
||||
Describe("parseWebSocketPermissions", func() {
|
||||
It("should parse valid WebSocket permissions", func() {
|
||||
permData := &schema.PluginManifestPermissionsWebsocket{
|
||||
Reason: "Need to connect to WebSocket API",
|
||||
AllowLocalNetwork: false,
|
||||
AllowedUrls: []string{"wss://api.example.com/ws", "wss://cdn.example.com/*"},
|
||||
}
|
||||
|
||||
perms, err := parseWebSocketPermissions(permData)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(perms).ToNot(BeNil())
|
||||
Expect(perms.AllowLocalNetwork).To(BeFalse())
|
||||
Expect(perms.AllowedUrls).To(Equal([]string{"wss://api.example.com/ws", "wss://cdn.example.com/*"}))
|
||||
})
|
||||
|
||||
It("should fail if allowedUrls is empty", func() {
|
||||
permData := &schema.PluginManifestPermissionsWebsocket{
|
||||
Reason: "Need to connect to WebSocket API",
|
||||
AllowLocalNetwork: false,
|
||||
AllowedUrls: []string{},
|
||||
}
|
||||
|
||||
_, err := parseWebSocketPermissions(permData)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern"))
|
||||
})
|
||||
|
||||
It("should handle wildcard patterns", func() {
|
||||
permData := &schema.PluginManifestPermissionsWebsocket{
|
||||
Reason: "Need to connect to any WebSocket",
|
||||
AllowLocalNetwork: true,
|
||||
AllowedUrls: []string{"wss://*"},
|
||||
}
|
||||
|
||||
perms, err := parseWebSocketPermissions(permData)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(perms.AllowLocalNetwork).To(BeTrue())
|
||||
Expect(perms.AllowedUrls).To(Equal([]string{"wss://*"}))
|
||||
})
|
||||
|
||||
Context("URL matching", func() {
|
||||
var perms *webSocketPermissions
|
||||
|
||||
BeforeEach(func() {
|
||||
permData := &schema.PluginManifestPermissionsWebsocket{
|
||||
Reason: "Need to connect to external services",
|
||||
AllowLocalNetwork: true,
|
||||
AllowedUrls: []string{"wss://api.example.com/*", "ws://localhost:8080"},
|
||||
}
|
||||
var err error
|
||||
perms, err = parseWebSocketPermissions(permData)
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should allow connections to URLs matching patterns", func() {
|
||||
err := perms.IsConnectionAllowed("wss://api.example.com/v1/stream")
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
err = perms.IsConnectionAllowed("ws://localhost:8080")
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
|
||||
It("should deny connections to URLs not matching patterns", func() {
|
||||
err := perms.IsConnectionAllowed("wss://malicious.com/stream")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("does not match any allowed URL patterns"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
231
plugins/host_websocket_test.go
Normal file
231
plugins/host_websocket_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gorillaws "github.com/gorilla/websocket"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/host/websocket"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("WebSocket Host Service", func() {
|
||||
var (
|
||||
wsService *websocketService
|
||||
manager *managerImpl
|
||||
ctx context.Context
|
||||
server *httptest.Server
|
||||
upgrader gorillaws.Upgrader
|
||||
serverMessages []string
|
||||
serverMu sync.Mutex
|
||||
)
|
||||
|
||||
// WebSocket echo server handler
|
||||
echoHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check headers
|
||||
if r.Header.Get("X-Test-Header") != "test-value" {
|
||||
http.Error(w, "Missing or invalid X-Test-Header", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Upgrade connection to WebSocket
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Echo messages back
|
||||
for {
|
||||
mt, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Store the received message for verification
|
||||
if mt == gorillaws.TextMessage {
|
||||
msg := string(message)
|
||||
serverMu.Lock()
|
||||
serverMessages = append(serverMessages, msg)
|
||||
serverMu.Unlock()
|
||||
}
|
||||
|
||||
// Echo it back
|
||||
err = conn.WriteMessage(mt, message)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// If message is "close", close the connection
|
||||
if mt == gorillaws.TextMessage && string(message) == "close" {
|
||||
_ = conn.WriteControl(
|
||||
gorillaws.CloseMessage,
|
||||
gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "bye"),
|
||||
time.Now().Add(time.Second),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
serverMessages = make([]string, 0)
|
||||
serverMu = sync.Mutex{}
|
||||
|
||||
// Create a test WebSocket server
|
||||
//upgrader = gorillaws.Upgrader{}
|
||||
server = httptest.NewServer(http.HandlerFunc(echoHandler))
|
||||
DeferCleanup(server.Close)
|
||||
|
||||
// Create a new manager and websocket service
|
||||
manager = createManager(nil, metrics.NewNoopInstance())
|
||||
wsService = newWebsocketService(manager)
|
||||
})
|
||||
|
||||
Describe("WebSocket operations", func() {
|
||||
var (
|
||||
pluginName string
|
||||
connectionID string
|
||||
wsURL string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
pluginName = "test-plugin"
|
||||
connectionID = "test-connection-id"
|
||||
wsURL = "ws" + strings.TrimPrefix(server.URL, "http")
|
||||
})
|
||||
|
||||
It("connects to a WebSocket server", func() {
|
||||
// Connect to the WebSocket server
|
||||
req := &websocket.ConnectRequest{
|
||||
Url: wsURL,
|
||||
Headers: map[string]string{
|
||||
"X-Test-Header": "test-value",
|
||||
},
|
||||
ConnectionId: connectionID,
|
||||
}
|
||||
|
||||
resp, err := wsService.connect(ctx, pluginName, req, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.ConnectionId).ToNot(BeEmpty())
|
||||
connectionID = resp.ConnectionId
|
||||
|
||||
// Verify that the connection was added to the service
|
||||
internalID := pluginName + ":" + connectionID
|
||||
Expect(wsService.hasConnection(internalID)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("sends and receives text messages", func() {
|
||||
// Connect to the WebSocket server
|
||||
req := &websocket.ConnectRequest{
|
||||
Url: wsURL,
|
||||
Headers: map[string]string{
|
||||
"X-Test-Header": "test-value",
|
||||
},
|
||||
ConnectionId: connectionID,
|
||||
}
|
||||
|
||||
resp, err := wsService.connect(ctx, pluginName, req, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
connectionID = resp.ConnectionId
|
||||
|
||||
// Send a text message
|
||||
textReq := &websocket.SendTextRequest{
|
||||
ConnectionId: connectionID,
|
||||
Message: "hello websocket",
|
||||
}
|
||||
|
||||
_, err = wsService.sendText(ctx, pluginName, textReq)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait a bit for the message to be processed
|
||||
Eventually(func() []string {
|
||||
serverMu.Lock()
|
||||
defer serverMu.Unlock()
|
||||
return serverMessages
|
||||
}, "1s").Should(ContainElement("hello websocket"))
|
||||
})
|
||||
|
||||
It("closes a WebSocket connection", func() {
|
||||
// Connect to the WebSocket server
|
||||
req := &websocket.ConnectRequest{
|
||||
Url: wsURL,
|
||||
Headers: map[string]string{
|
||||
"X-Test-Header": "test-value",
|
||||
},
|
||||
ConnectionId: connectionID,
|
||||
}
|
||||
|
||||
resp, err := wsService.connect(ctx, pluginName, req, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
connectionID = resp.ConnectionId
|
||||
|
||||
initialCount := wsService.connectionCount()
|
||||
|
||||
// Close the connection
|
||||
closeReq := &websocket.CloseRequest{
|
||||
ConnectionId: connectionID,
|
||||
Code: 1000, // Normal closure
|
||||
Reason: "test complete",
|
||||
}
|
||||
|
||||
_, err = wsService.close(ctx, pluginName, closeReq)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify that the connection was removed
|
||||
Eventually(func() int {
|
||||
return wsService.connectionCount()
|
||||
}, "1s").Should(Equal(initialCount - 1))
|
||||
|
||||
internalID := pluginName + ":" + connectionID
|
||||
Expect(wsService.hasConnection(internalID)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("handles connection errors gracefully", func() {
|
||||
if testing.Short() {
|
||||
GinkgoT().Skip("skipping test in short mode.")
|
||||
}
|
||||
|
||||
// Try to connect to an invalid URL
|
||||
req := &websocket.ConnectRequest{
|
||||
Url: "ws://invalid-url-that-does-not-exist",
|
||||
Headers: map[string]string{},
|
||||
ConnectionId: connectionID,
|
||||
}
|
||||
|
||||
_, err := wsService.connect(ctx, pluginName, req, nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when attempting to use non-existent connection", func() {
|
||||
// Try to send a message to a non-existent connection
|
||||
textReq := &websocket.SendTextRequest{
|
||||
ConnectionId: "non-existent-connection",
|
||||
Message: "this should fail",
|
||||
}
|
||||
|
||||
sendResp, err := wsService.sendText(ctx, pluginName, textReq)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sendResp.Error).To(ContainSubstring("connection not found"))
|
||||
|
||||
// Try to close a non-existent connection
|
||||
closeReq := &websocket.CloseRequest{
|
||||
ConnectionId: "non-existent-connection",
|
||||
Code: 1000,
|
||||
Reason: "test complete",
|
||||
}
|
||||
|
||||
closeResp, err := wsService.close(ctx, pluginName, closeReq)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(closeResp.Error).To(ContainSubstring("connection not found"))
|
||||
})
|
||||
})
|
||||
})
|
||||
421
plugins/manager.go
Normal file
421
plugins/manager.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package plugins
|
||||
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative api/api.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/http/http.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/config/config.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/websocket/websocket.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto
|
||||
//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityMetadataAgent = "MetadataAgent"
|
||||
CapabilityScrobbler = "Scrobbler"
|
||||
CapabilitySchedulerCallback = "SchedulerCallback"
|
||||
CapabilityWebSocketCallback = "WebSocketCallback"
|
||||
CapabilityLifecycleManagement = "LifecycleManagement"
|
||||
)
|
||||
|
||||
// pluginCreators maps capability types to their respective creator functions
|
||||
type pluginConstructor func(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin
|
||||
|
||||
var pluginCreators = map[string]pluginConstructor{
|
||||
CapabilityMetadataAgent: newWasmMediaAgent,
|
||||
CapabilityScrobbler: newWasmScrobblerPlugin,
|
||||
CapabilitySchedulerCallback: newWasmSchedulerCallback,
|
||||
CapabilityWebSocketCallback: newWasmWebSocketCallback,
|
||||
}
|
||||
|
||||
// WasmPlugin is the base interface that all WASM plugins implement
|
||||
type WasmPlugin interface {
|
||||
// PluginID returns the unique identifier of the plugin (folder name)
|
||||
PluginID() string
|
||||
}
|
||||
|
||||
type plugin struct {
|
||||
ID string
|
||||
Path string
|
||||
Capabilities []string
|
||||
WasmPath string
|
||||
Manifest *schema.PluginManifest // Loaded manifest
|
||||
Runtime api.WazeroNewRuntime
|
||||
ModConfig wazero.ModuleConfig
|
||||
compilationReady chan struct{}
|
||||
compilationErr error
|
||||
}
|
||||
|
||||
func (p *plugin) waitForCompilation() error {
|
||||
timeout := pluginCompilationTimeout()
|
||||
select {
|
||||
case <-p.compilationReady:
|
||||
case <-time.After(timeout):
|
||||
err := fmt.Errorf("timed out waiting for plugin %s to compile", p.ID)
|
||||
log.Error("Timed out waiting for plugin compilation", "name", p.ID, "path", p.WasmPath, "timeout", timeout, "err", err)
|
||||
return err
|
||||
}
|
||||
if p.compilationErr != nil {
|
||||
log.Error("Failed to compile plugin", "name", p.ID, "path", p.WasmPath, p.compilationErr)
|
||||
}
|
||||
return p.compilationErr
|
||||
}
|
||||
|
||||
type SubsonicRouter http.Handler
|
||||
|
||||
type Manager interface {
|
||||
SetSubsonicRouter(router SubsonicRouter)
|
||||
EnsureCompiled(name string) error
|
||||
PluginList() map[string]schema.PluginManifest
|
||||
PluginNames(capability string) []string
|
||||
LoadPlugin(name string, capability string) WasmPlugin
|
||||
LoadMediaAgent(name string) (agents.Interface, bool)
|
||||
LoadScrobbler(name string) (scrobbler.Scrobbler, bool)
|
||||
ScanPlugins()
|
||||
}
|
||||
|
||||
// managerImpl is a singleton that manages plugins
|
||||
type managerImpl struct {
|
||||
plugins map[string]*plugin // Map of plugin folder name to plugin info
|
||||
pluginsMu sync.RWMutex // Protects plugins map
|
||||
subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router
|
||||
schedulerService *schedulerService // Service for handling scheduled tasks
|
||||
websocketService *websocketService // Service for handling WebSocket connections
|
||||
lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization
|
||||
adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter
|
||||
ds model.DataStore // DataStore for accessing persistent data
|
||||
metrics metrics.Metrics
|
||||
}
|
||||
|
||||
// GetManager returns the singleton instance of managerImpl
|
||||
func GetManager(ds model.DataStore, metrics metrics.Metrics) Manager {
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
return &noopManager{}
|
||||
}
|
||||
return singleton.GetInstance(func() *managerImpl {
|
||||
return createManager(ds, metrics)
|
||||
})
|
||||
}
|
||||
|
||||
// createManager creates a new managerImpl instance. Used in tests
|
||||
func createManager(ds model.DataStore, metrics metrics.Metrics) *managerImpl {
|
||||
m := &managerImpl{
|
||||
plugins: make(map[string]*plugin),
|
||||
lifecycle: newPluginLifecycleManager(metrics),
|
||||
ds: ds,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
// Create the host services
|
||||
m.schedulerService = newSchedulerService(m)
|
||||
m.websocketService = newWebsocketService(m)
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSubsonicRouter sets the SubsonicRouter after managerImpl initialization
|
||||
func (m *managerImpl) SetSubsonicRouter(router SubsonicRouter) {
|
||||
m.subsonicRouter.Store(&router)
|
||||
}
|
||||
|
||||
// registerPlugin adds a plugin to the registry with the given parameters
|
||||
// Used internally by ScanPlugins to register plugins
|
||||
func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin {
|
||||
// Create custom runtime function
|
||||
customRuntime := m.createRuntime(pluginID, manifest.Permissions)
|
||||
|
||||
// Configure module and determine plugin name
|
||||
mc := newWazeroModuleConfig()
|
||||
|
||||
// Check if it's a symlink, indicating development mode
|
||||
isSymlink := false
|
||||
if fileInfo, err := os.Lstat(pluginDir); err == nil {
|
||||
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
// Store plugin info
|
||||
p := &plugin{
|
||||
ID: pluginID,
|
||||
Path: pluginDir,
|
||||
Capabilities: slice.Map(manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { return string(cap) }),
|
||||
WasmPath: wasmPath,
|
||||
Manifest: manifest,
|
||||
Runtime: customRuntime,
|
||||
ModConfig: mc,
|
||||
compilationReady: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Register the plugin first
|
||||
m.pluginsMu.Lock()
|
||||
m.plugins[pluginID] = p
|
||||
|
||||
// Register one plugin adapter for each capability
|
||||
for _, capability := range manifest.Capabilities {
|
||||
capabilityStr := string(capability)
|
||||
constructor := pluginCreators[capabilityStr]
|
||||
if constructor == nil {
|
||||
// Warn about unknown capabilities, except for LifecycleManagement (it does not have an adapter)
|
||||
if capability != CapabilityLifecycleManagement {
|
||||
log.Warn("Unknown plugin capability type", "capability", capability, "plugin", pluginID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
adapter := constructor(wasmPath, pluginID, m, customRuntime, mc)
|
||||
if adapter == nil {
|
||||
log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath)
|
||||
continue
|
||||
}
|
||||
m.adapters[pluginID+"_"+capabilityStr] = adapter
|
||||
}
|
||||
m.pluginsMu.Unlock()
|
||||
|
||||
log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink)
|
||||
return m.plugins[pluginID]
|
||||
}
|
||||
|
||||
// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement
|
||||
func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) {
|
||||
// Skip if already initialized
|
||||
if m.lifecycle.isInitialized(plugin) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the plugin implements LifecycleManagement
|
||||
if slices.Contains(plugin.Manifest.Capabilities, CapabilityLifecycleManagement) {
|
||||
if err := m.lifecycle.callOnInit(plugin); err != nil {
|
||||
m.unregisterPlugin(plugin.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unregisterPlugin removes a plugin from the manager
|
||||
func (m *managerImpl) unregisterPlugin(pluginID string) {
|
||||
m.pluginsMu.Lock()
|
||||
defer m.pluginsMu.Unlock()
|
||||
|
||||
plugin, ok := m.plugins[pluginID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear initialization state from lifecycle manager
|
||||
m.lifecycle.clearInitialized(plugin)
|
||||
|
||||
// Unregister plugin adapters
|
||||
for _, capability := range plugin.Manifest.Capabilities {
|
||||
delete(m.adapters, pluginID+"_"+string(capability))
|
||||
}
|
||||
|
||||
// Unregister plugin
|
||||
delete(m.plugins, pluginID)
|
||||
log.Info("Unregistered plugin", "plugin", pluginID)
|
||||
}
|
||||
|
||||
// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use.
|
||||
func (m *managerImpl) ScanPlugins() {
|
||||
// Clear existing plugins
|
||||
m.pluginsMu.Lock()
|
||||
m.plugins = make(map[string]*plugin)
|
||||
m.adapters = make(map[string]WasmPlugin)
|
||||
m.pluginsMu.Unlock()
|
||||
|
||||
// Get plugins directory from config
|
||||
root := conf.Server.Plugins.Folder
|
||||
log.Debug("Scanning plugins folder", "root", root)
|
||||
|
||||
// Fail fast if the compilation cache cannot be initialized
|
||||
_, err := getCompilationCache()
|
||||
if err != nil {
|
||||
log.Error("Failed to initialize plugins compilation cache. Disabling plugins", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Discover all plugins using the shared discovery function
|
||||
discoveries := DiscoverPlugins(root)
|
||||
|
||||
var validPluginNames []string
|
||||
var registeredPlugins []*plugin
|
||||
for _, discovery := range discoveries {
|
||||
if discovery.Error != nil {
|
||||
// Handle global errors (like directory read failure)
|
||||
if discovery.ID == "" {
|
||||
log.Error("Plugin discovery failed", discovery.Error)
|
||||
return
|
||||
}
|
||||
// Handle individual plugin errors
|
||||
log.Error("Failed to process plugin", "plugin", discovery.ID, discovery.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
// Log discovery details
|
||||
log.Debug("Processing entry", "name", discovery.ID, "isSymlink", discovery.IsSymlink)
|
||||
if discovery.IsSymlink {
|
||||
log.Debug("Processing symlinked plugin directory", "name", discovery.ID, "target", discovery.Path)
|
||||
}
|
||||
log.Debug("Checking for plugin.wasm", "wasmPath", discovery.WasmPath)
|
||||
log.Debug("Manifest loaded successfully", "folder", discovery.ID, "name", discovery.Manifest.Name, "capabilities", discovery.Manifest.Capabilities)
|
||||
|
||||
validPluginNames = append(validPluginNames, discovery.ID)
|
||||
|
||||
// Register the plugin
|
||||
plugin := m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest)
|
||||
if plugin != nil {
|
||||
registeredPlugins = append(registeredPlugins, plugin)
|
||||
}
|
||||
}
|
||||
|
||||
// Start background processing for all registered plugins after registration is complete
|
||||
// This avoids race conditions between registration and goroutines that might unregister plugins
|
||||
for _, p := range registeredPlugins {
|
||||
go func(plugin *plugin) {
|
||||
precompilePlugin(plugin)
|
||||
// Check if this plugin implements InitService and hasn't been initialized yet
|
||||
m.initializePluginIfNeeded(plugin)
|
||||
}(p)
|
||||
}
|
||||
|
||||
log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames)
|
||||
}
|
||||
|
||||
// PluginList returns a map of all registered plugins with their manifests
|
||||
func (m *managerImpl) PluginList() map[string]schema.PluginManifest {
|
||||
m.pluginsMu.RLock()
|
||||
defer m.pluginsMu.RUnlock()
|
||||
|
||||
// Create a map to hold the plugin manifests
|
||||
pluginList := make(map[string]schema.PluginManifest, len(m.plugins))
|
||||
for name, plugin := range m.plugins {
|
||||
// Use the plugin ID as the key and the manifest as the value
|
||||
pluginList[name] = *plugin.Manifest
|
||||
}
|
||||
return pluginList
|
||||
}
|
||||
|
||||
// PluginNames returns the folder names of all plugins that implement the specified capability
|
||||
func (m *managerImpl) PluginNames(capability string) []string {
|
||||
m.pluginsMu.RLock()
|
||||
defer m.pluginsMu.RUnlock()
|
||||
|
||||
var names []string
|
||||
for name, plugin := range m.plugins {
|
||||
for _, c := range plugin.Manifest.Capabilities {
|
||||
if string(c) == capability {
|
||||
names = append(names, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) {
|
||||
m.pluginsMu.RLock()
|
||||
defer m.pluginsMu.RUnlock()
|
||||
info, infoOk := m.plugins[name]
|
||||
adapter, adapterOk := m.adapters[name+"_"+capability]
|
||||
|
||||
if !infoOk {
|
||||
return nil, nil, fmt.Errorf("plugin not registered: %s", name)
|
||||
}
|
||||
if !adapterOk {
|
||||
return nil, nil, fmt.Errorf("plugin adapter not registered: %s, capability: %s", name, capability)
|
||||
}
|
||||
return info, adapter, nil
|
||||
}
|
||||
|
||||
// LoadPlugin instantiates and returns a plugin by folder name
|
||||
func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin {
|
||||
info, adapter, err := m.getPlugin(name, capability)
|
||||
if err != nil {
|
||||
log.Warn("Error loading plugin", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("Loading plugin", "name", name, "path", info.Path)
|
||||
|
||||
// Wait for the plugin to be ready before using it.
|
||||
if err := info.waitForCompilation(); err != nil {
|
||||
log.Error("Plugin is not ready, cannot be loaded", "plugin", name, "capability", capability, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if adapter == nil {
|
||||
log.Warn("Plugin adapter not found", "name", name, "capability", capability)
|
||||
return nil
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
|
||||
// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error.
|
||||
// This is useful when you need to wait for compilation without loading a specific capability,
|
||||
// such as during plugin refresh operations or health checks.
|
||||
func (m *managerImpl) EnsureCompiled(name string) error {
|
||||
m.pluginsMu.RLock()
|
||||
plugin, ok := m.plugins[name]
|
||||
m.pluginsMu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin not found: %s", name)
|
||||
}
|
||||
|
||||
return plugin.waitForCompilation()
|
||||
}
|
||||
|
||||
// LoadMediaAgent instantiates and returns a media agent plugin by folder name
|
||||
func (m *managerImpl) LoadMediaAgent(name string) (agents.Interface, bool) {
|
||||
plugin := m.LoadPlugin(name, CapabilityMetadataAgent)
|
||||
if plugin == nil {
|
||||
return nil, false
|
||||
}
|
||||
agent, ok := plugin.(*wasmMediaAgent)
|
||||
return agent, ok
|
||||
}
|
||||
|
||||
// LoadScrobbler instantiates and returns a scrobbler plugin by folder name
|
||||
func (m *managerImpl) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) {
|
||||
plugin := m.LoadPlugin(name, CapabilityScrobbler)
|
||||
if plugin == nil {
|
||||
return nil, false
|
||||
}
|
||||
s, ok := plugin.(scrobbler.Scrobbler)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
type noopManager struct{}
|
||||
|
||||
func (n noopManager) SetSubsonicRouter(router SubsonicRouter) {}
|
||||
|
||||
func (n noopManager) EnsureCompiled(name string) error { return nil }
|
||||
|
||||
func (n noopManager) PluginList() map[string]schema.PluginManifest { return nil }
|
||||
|
||||
func (n noopManager) PluginNames(capability string) []string { return nil }
|
||||
|
||||
func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil }
|
||||
|
||||
func (n noopManager) LoadMediaAgent(name string) (agents.Interface, bool) { return nil, false }
|
||||
|
||||
func (n noopManager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return nil, false }
|
||||
|
||||
func (n noopManager) ScanPlugins() {}
|
||||
367
plugins/manager_test.go
Normal file
367
plugins/manager_test.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Plugin Manager", func() {
|
||||
var mgr *managerImpl
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
// We change the plugins folder to random location to avoid conflicts with other tests,
|
||||
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
|
||||
// data races.
|
||||
originalPluginsFolder := conf.Server.Plugins.Folder
|
||||
originalTimeout := conf.Server.DevPluginCompilationTimeout
|
||||
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
||||
DeferCleanup(func() {
|
||||
conf.Server.Plugins.Folder = originalPluginsFolder
|
||||
conf.Server.DevPluginCompilationTimeout = originalTimeout
|
||||
})
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
|
||||
// Wait for all plugins to compile to avoid race conditions
|
||||
err := mgr.EnsureCompiled("fake_artist_agent")
|
||||
Expect(err).NotTo(HaveOccurred(), "fake_artist_agent should compile successfully")
|
||||
err = mgr.EnsureCompiled("fake_album_agent")
|
||||
Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
|
||||
err = mgr.EnsureCompiled("multi_plugin")
|
||||
Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
|
||||
err = mgr.EnsureCompiled("unauthorized_plugin")
|
||||
Expect(err).NotTo(HaveOccurred(), "unauthorized_plugin should compile successfully")
|
||||
})
|
||||
|
||||
It("should scan and discover plugins from the testdata folder", func() {
|
||||
Expect(mgr).NotTo(BeNil())
|
||||
|
||||
mediaAgentNames := mgr.PluginNames("MetadataAgent")
|
||||
Expect(mediaAgentNames).To(HaveLen(4))
|
||||
Expect(mediaAgentNames).To(ContainElements(
|
||||
"fake_artist_agent",
|
||||
"fake_album_agent",
|
||||
"multi_plugin",
|
||||
"unauthorized_plugin",
|
||||
))
|
||||
|
||||
scrobblerNames := mgr.PluginNames("Scrobbler")
|
||||
Expect(scrobblerNames).To(ContainElement("fake_scrobbler"))
|
||||
|
||||
initServiceNames := mgr.PluginNames("LifecycleManagement")
|
||||
Expect(initServiceNames).To(ContainElements("multi_plugin", "fake_init_service"))
|
||||
|
||||
schedulerCallbackNames := mgr.PluginNames("SchedulerCallback")
|
||||
Expect(schedulerCallbackNames).To(ContainElement("multi_plugin"))
|
||||
})
|
||||
|
||||
It("should load all plugins from folder", func() {
|
||||
all := mgr.PluginList()
|
||||
Expect(all).To(HaveLen(6))
|
||||
Expect(all["fake_artist_agent"].Name).To(Equal("fake_artist_agent"))
|
||||
Expect(all["unauthorized_plugin"].Capabilities).To(HaveExactElements(schema.PluginManifestCapabilitiesElem("MetadataAgent")))
|
||||
})
|
||||
|
||||
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
|
||||
plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent)
|
||||
Expect(plugin).NotTo(BeNil())
|
||||
|
||||
agent, ok := plugin.(agents.Interface)
|
||||
Expect(ok).To(BeTrue(), "plugin should implement agents.Interface")
|
||||
Expect(agent.AgentName()).To(Equal("fake_artist_agent"))
|
||||
|
||||
mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever)
|
||||
Expect(ok).To(BeTrue())
|
||||
mbid, err := mbidRetriever.GetArtistMBID(ctx, "123", "The Beatles")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mbid).To(Equal("1234567890"))
|
||||
})
|
||||
|
||||
It("should load all MetadataAgent plugins", func() {
|
||||
mediaAgentNames := mgr.PluginNames("MetadataAgent")
|
||||
Expect(mediaAgentNames).To(HaveLen(4))
|
||||
|
||||
var agentNames []string
|
||||
for _, name := range mediaAgentNames {
|
||||
agent, ok := mgr.LoadMediaAgent(name)
|
||||
if ok {
|
||||
agentNames = append(agentNames, agent.AgentName())
|
||||
}
|
||||
}
|
||||
|
||||
Expect(agentNames).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin"))
|
||||
})
|
||||
|
||||
Describe("ScanPlugins", func() {
|
||||
var tempPluginsDir string
|
||||
var m *managerImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*")
|
||||
DeferCleanup(func() {
|
||||
_ = os.RemoveAll(tempPluginsDir)
|
||||
})
|
||||
|
||||
conf.Server.Plugins.Folder = tempPluginsDir
|
||||
m = createManager(nil, metrics.NewNoopInstance())
|
||||
})
|
||||
|
||||
// Helper to create a complete valid plugin for manager testing
|
||||
createValidPlugin := func(folderName, manifestName string) {
|
||||
pluginDir := filepath.Join(tempPluginsDir, folderName)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Copy real WASM file from testdata
|
||||
sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm")
|
||||
targetWasmPath := filepath.Join(pluginDir, "plugin.wasm")
|
||||
sourceWasm, err := os.ReadFile(sourceWasmPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "` + manifestName + `",
|
||||
"version": "1.0.0",
|
||||
"capabilities": ["MetadataAgent"],
|
||||
"author": "Test Author",
|
||||
"description": "Test Plugin",
|
||||
"website": "https://test.navidrome.org/` + manifestName + `",
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
}
|
||||
|
||||
It("should register and compile discovered plugins", func() {
|
||||
createValidPlugin("test-plugin", "test-plugin")
|
||||
|
||||
m.ScanPlugins()
|
||||
|
||||
// Focus on manager behavior: registration and compilation
|
||||
Expect(m.plugins).To(HaveLen(1))
|
||||
Expect(m.plugins).To(HaveKey("test-plugin"))
|
||||
|
||||
plugin := m.plugins["test-plugin"]
|
||||
Expect(plugin.ID).To(Equal("test-plugin"))
|
||||
Expect(plugin.Manifest.Name).To(Equal("test-plugin"))
|
||||
|
||||
// Verify plugin can be loaded (compilation successful)
|
||||
loadedPlugin := m.LoadPlugin("test-plugin", CapabilityMetadataAgent)
|
||||
Expect(loadedPlugin).NotTo(BeNil())
|
||||
})
|
||||
|
||||
It("should handle multiple plugins with different IDs but same manifest names", func() {
|
||||
// This tests manager-specific behavior: how it handles ID conflicts
|
||||
createValidPlugin("lastfm-official", "lastfm")
|
||||
createValidPlugin("lastfm-custom", "lastfm")
|
||||
|
||||
m.ScanPlugins()
|
||||
|
||||
// Both should be registered with their folder names as IDs
|
||||
Expect(m.plugins).To(HaveLen(2))
|
||||
Expect(m.plugins).To(HaveKey("lastfm-official"))
|
||||
Expect(m.plugins).To(HaveKey("lastfm-custom"))
|
||||
|
||||
// Both should be loadable independently
|
||||
official := m.LoadPlugin("lastfm-official", CapabilityMetadataAgent)
|
||||
custom := m.LoadPlugin("lastfm-custom", CapabilityMetadataAgent)
|
||||
Expect(official).NotTo(BeNil())
|
||||
Expect(custom).NotTo(BeNil())
|
||||
Expect(official.PluginID()).To(Equal("lastfm-official"))
|
||||
Expect(custom.PluginID()).To(Equal("lastfm-custom"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("LoadPlugin", func() {
|
||||
It("should load a MetadataAgent plugin and invoke artist-related methods", func() {
|
||||
plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent)
|
||||
Expect(plugin).NotTo(BeNil())
|
||||
|
||||
agent, ok := plugin.(agents.Interface)
|
||||
Expect(ok).To(BeTrue(), "plugin should implement agents.Interface")
|
||||
Expect(agent.AgentName()).To(Equal("fake_artist_agent"))
|
||||
|
||||
mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever)
|
||||
Expect(ok).To(BeTrue())
|
||||
mbid, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mbid).To(Equal("1234567890"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnsureCompiled", func() {
|
||||
It("should successfully wait for plugin compilation", func() {
|
||||
err := mgr.EnsureCompiled("fake_artist_agent")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error for non-existent plugin", func() {
|
||||
err := mgr.EnsureCompiled("non-existent-plugin")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("plugin not found: non-existent-plugin"))
|
||||
})
|
||||
|
||||
It("should wait for compilation to complete for all valid plugins", func() {
|
||||
pluginNames := []string{"fake_artist_agent", "fake_album_agent", "multi_plugin", "fake_scrobbler"}
|
||||
|
||||
for _, name := range pluginNames {
|
||||
err := mgr.EnsureCompiled(name)
|
||||
Expect(err).NotTo(HaveOccurred(), "plugin %s should compile successfully", name)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Invoke Methods", func() {
|
||||
It("should load all MetadataAgent plugins and invoke methods", func() {
|
||||
fakeAlbumPlugin, isMediaAgent := mgr.LoadMediaAgent("fake_album_agent")
|
||||
Expect(isMediaAgent).To(BeTrue())
|
||||
|
||||
Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded")
|
||||
|
||||
// Test GetAlbumInfo method - need to cast to the specific interface
|
||||
albumRetriever, ok := fakeAlbumPlugin.(agents.AlbumInfoRetriever)
|
||||
Expect(ok).To(BeTrue(), "fake_album_agent should implement AlbumInfoRetriever")
|
||||
|
||||
info, err := albumRetriever.GetAlbumInfo(ctx, "Test Album", "Test Artist", "123")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(info).NotTo(BeNil())
|
||||
Expect(info.Name).To(Equal("Test Album"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Permission Enforcement Integration", func() {
|
||||
It("should fail when plugin tries to access unauthorized services", func() {
|
||||
// This plugin tries to access config service but has no permissions
|
||||
plugin := mgr.LoadPlugin("unauthorized_plugin", CapabilityMetadataAgent)
|
||||
Expect(plugin).NotTo(BeNil())
|
||||
|
||||
agent, ok := plugin.(agents.Interface)
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
// This should fail because the plugin tries to access unauthorized config service
|
||||
// The exact behavior depends on the plugin implementation, but it should either:
|
||||
// 1. Fail during instantiation, or
|
||||
// 2. Return an error when trying to call config methods
|
||||
|
||||
// Try to use one of the available methods - let's test with GetArtistMBID
|
||||
mbidRetriever, isMBIDRetriever := agent.(agents.ArtistMBIDRetriever)
|
||||
if isMBIDRetriever {
|
||||
_, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist")
|
||||
if err == nil {
|
||||
// If no error, the plugin should still be working
|
||||
// but any config access should fail silently or return default values
|
||||
Expect(agent.AgentName()).To(Equal("unauthorized_plugin"))
|
||||
} else {
|
||||
// If there's an error, it should be related to missing permissions
|
||||
Expect(err.Error()).To(ContainSubstring(""))
|
||||
}
|
||||
} else {
|
||||
// If the plugin doesn't implement the interface, that's also acceptable
|
||||
Expect(agent.AgentName()).To(Equal("unauthorized_plugin"))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Initialization Lifecycle", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
})
|
||||
|
||||
Context("when OnInit is successful", func() {
|
||||
It("should register and initialize the plugin", func() {
|
||||
conf.Server.PluginConfig = nil
|
||||
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
||||
mgr.ScanPlugins()
|
||||
|
||||
plugin := mgr.plugins["fake_init_service"]
|
||||
Expect(plugin).NotTo(BeNil())
|
||||
|
||||
Eventually(func() bool {
|
||||
return mgr.lifecycle.isInitialized(plugin)
|
||||
}).Should(BeTrue())
|
||||
|
||||
// Check that the plugin is still registered
|
||||
names := mgr.PluginNames(CapabilityLifecycleManagement)
|
||||
Expect(names).To(ContainElement("fake_init_service"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when OnInit fails", func() {
|
||||
It("should unregister the plugin if OnInit returns an error string", func() {
|
||||
conf.Server.PluginConfig = map[string]map[string]string{
|
||||
"fake_init_service": {
|
||||
"returnError": "response_error",
|
||||
},
|
||||
}
|
||||
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
||||
mgr.ScanPlugins()
|
||||
|
||||
Eventually(func() []string {
|
||||
return mgr.PluginNames(CapabilityLifecycleManagement)
|
||||
}).ShouldNot(ContainElement("fake_init_service"))
|
||||
})
|
||||
|
||||
It("should unregister the plugin if OnInit returns a Go error", func() {
|
||||
conf.Server.PluginConfig = map[string]map[string]string{
|
||||
"fake_init_service": {
|
||||
"returnError": "go_error",
|
||||
},
|
||||
}
|
||||
mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config
|
||||
mgr.ScanPlugins()
|
||||
|
||||
Eventually(func() []string {
|
||||
return mgr.PluginNames(CapabilityLifecycleManagement)
|
||||
}).ShouldNot(ContainElement("fake_init_service"))
|
||||
})
|
||||
})
|
||||
|
||||
It("should clear lifecycle state when unregistering a plugin", func() {
|
||||
// Create a manager and register a plugin
|
||||
mgr := createManager(nil, metrics.NewNoopInstance())
|
||||
|
||||
// Create a mock plugin with LifecycleManagement capability
|
||||
plugin := &plugin{
|
||||
ID: "test-plugin",
|
||||
Capabilities: []string{CapabilityLifecycleManagement},
|
||||
Manifest: &schema.PluginManifest{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
|
||||
// Register the plugin in the manager
|
||||
mgr.pluginsMu.Lock()
|
||||
mgr.plugins[plugin.ID] = plugin
|
||||
mgr.pluginsMu.Unlock()
|
||||
|
||||
// Mark the plugin as initialized in the lifecycle manager
|
||||
mgr.lifecycle.markInitialized(plugin)
|
||||
Expect(mgr.lifecycle.isInitialized(plugin)).To(BeTrue())
|
||||
|
||||
// Unregister the plugin
|
||||
mgr.unregisterPlugin(plugin.ID)
|
||||
|
||||
// Verify that the plugin is no longer in the manager
|
||||
mgr.pluginsMu.RLock()
|
||||
_, exists := mgr.plugins[plugin.ID]
|
||||
mgr.pluginsMu.RUnlock()
|
||||
Expect(exists).To(BeFalse())
|
||||
|
||||
// Verify that the lifecycle state has been cleared
|
||||
Expect(mgr.lifecycle.isInitialized(plugin)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user