diff --git a/plugins/host/artwork.go b/plugins/host/artwork.go new file mode 100644 index 000000000..1a575f21e --- /dev/null +++ b/plugins/host/artwork.go @@ -0,0 +1,53 @@ +package host + +import "context" + +// ArtworkService provides artwork URL generation capabilities for plugins. +// +// This service allows plugins to generate public URLs for artwork images of +// various entity types (artists, albums, tracks, playlists). The generated URLs +// include authentication tokens and can be used to display artwork in external +// services or custom UIs. +// +//nd:hostservice name=Artwork permission=artwork +type ArtworkService interface { + // GetArtistUrl generates a public URL for an artist's artwork. + // + // Parameters: + // - id: The artist's unique identifier + // - size: Desired image size in pixels (0 for original size) + // + // Returns the public URL for the artwork, or an error if generation fails. + //nd:hostfunc + GetArtistUrl(ctx context.Context, id string, size int32) (url string, err error) + + // GetAlbumUrl generates a public URL for an album's artwork. + // + // Parameters: + // - id: The album's unique identifier + // - size: Desired image size in pixels (0 for original size) + // + // Returns the public URL for the artwork, or an error if generation fails. + //nd:hostfunc + GetAlbumUrl(ctx context.Context, id string, size int32) (url string, err error) + + // GetTrackUrl generates a public URL for a track's artwork. + // + // Parameters: + // - id: The track's (media file) unique identifier + // - size: Desired image size in pixels (0 for original size) + // + // Returns the public URL for the artwork, or an error if generation fails. + //nd:hostfunc + GetTrackUrl(ctx context.Context, id string, size int32) (url string, err error) + + // GetPlaylistUrl generates a public URL for a playlist's artwork. + // + // Parameters: + // - id: The playlist's unique identifier + // - size: Desired image size in pixels (0 for original size) + // + // Returns the public URL for the artwork, or an error if generation fails. + //nd:hostfunc + GetPlaylistUrl(ctx context.Context, id string, size int32) (url string, err error) +} diff --git a/plugins/host/artwork_gen.go b/plugins/host/artwork_gen.go new file mode 100644 index 000000000..4615ef1d0 --- /dev/null +++ b/plugins/host/artwork_gen.go @@ -0,0 +1,182 @@ +// Code generated by hostgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// ArtworkGetArtistUrlResponse is the response type for Artwork.GetArtistUrl. +type ArtworkGetArtistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetAlbumUrlResponse is the response type for Artwork.GetAlbumUrl. +type ArtworkGetAlbumUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetTrackUrlResponse is the response type for Artwork.GetTrackUrl. +type ArtworkGetTrackUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetPlaylistUrlResponse is the response type for Artwork.GetPlaylistUrl. +type ArtworkGetPlaylistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterArtworkHostFunctions registers Artwork service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterArtworkHostFunctions(service ArtworkService) []extism.HostFunction { + return []extism.HostFunction{ + newArtworkGetArtistUrlHostFunction(service), + newArtworkGetAlbumUrlHostFunction(service), + newArtworkGetTrackUrlHostFunction(service), + newArtworkGetPlaylistUrlHostFunction(service), + } +} + +func newArtworkGetArtistUrlHostFunction(service ArtworkService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "artwork_getartisturl", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + id, err := p.ReadString(stack[0]) + if err != nil { + return + } + size := extism.DecodeI32(stack[1]) + + // Call the service method + url, err := service.GetArtistUrl(ctx, id, size) + if err != nil { + artworkWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := ArtworkGetArtistUrlResponse{ + Url: url, + } + artworkWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypeI32}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newArtworkGetAlbumUrlHostFunction(service ArtworkService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "artwork_getalbumurl", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + id, err := p.ReadString(stack[0]) + if err != nil { + return + } + size := extism.DecodeI32(stack[1]) + + // Call the service method + url, err := service.GetAlbumUrl(ctx, id, size) + if err != nil { + artworkWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := ArtworkGetAlbumUrlResponse{ + Url: url, + } + artworkWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypeI32}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newArtworkGetTrackUrlHostFunction(service ArtworkService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "artwork_gettrackurl", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + id, err := p.ReadString(stack[0]) + if err != nil { + return + } + size := extism.DecodeI32(stack[1]) + + // Call the service method + url, err := service.GetTrackUrl(ctx, id, size) + if err != nil { + artworkWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := ArtworkGetTrackUrlResponse{ + Url: url, + } + artworkWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypeI32}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newArtworkGetPlaylistUrlHostFunction(service ArtworkService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "artwork_getplaylisturl", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + id, err := p.ReadString(stack[0]) + if err != nil { + return + } + size := extism.DecodeI32(stack[1]) + + // Call the service method + url, err := service.GetPlaylistUrl(ctx, id, size) + if err != nil { + artworkWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := ArtworkGetPlaylistUrlResponse{ + Url: url, + } + artworkWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypeI32}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// artworkWriteResponse writes a JSON response to plugin memory. +func artworkWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + artworkWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// artworkWriteError writes an error response to plugin memory. +func artworkWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host/go/nd_host_artwork.go b/plugins/host/go/nd_host_artwork.go new file mode 100644 index 000000000..0943c794c --- /dev/null +++ b/plugins/host/go/nd_host_artwork.go @@ -0,0 +1,170 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Artwork host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + + "github.com/extism/go-pdk" +) + +// artwork_getartisturl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getartisturl +func artwork_getartisturl(uint64, int32) uint64 + +// artwork_getalbumurl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getalbumurl +func artwork_getalbumurl(uint64, int32) uint64 + +// artwork_gettrackurl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_gettrackurl +func artwork_gettrackurl(uint64, int32) uint64 + +// artwork_getplaylisturl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getplaylisturl +func artwork_getplaylisturl(uint64, int32) uint64 + +// ArtworkGetArtistUrlResponse is the response type for Artwork.GetArtistUrl. +type ArtworkGetArtistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetAlbumUrlResponse is the response type for Artwork.GetAlbumUrl. +type ArtworkGetAlbumUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetTrackUrlResponse is the response type for Artwork.GetTrackUrl. +type ArtworkGetTrackUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetPlaylistUrlResponse is the response type for Artwork.GetPlaylistUrl. +type ArtworkGetPlaylistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetArtistUrl calls the artwork_getartisturl host function. +// GetArtistUrl generates a public URL for an artist's artwork. +// +// Parameters: +// - id: The artist's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetArtistUrl(id string, size int32) (*ArtworkGetArtistUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getartisturl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetArtistUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetAlbumUrl calls the artwork_getalbumurl host function. +// GetAlbumUrl generates a public URL for an album's artwork. +// +// Parameters: +// - id: The album's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetAlbumUrl(id string, size int32) (*ArtworkGetAlbumUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getalbumurl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetAlbumUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetTrackUrl calls the artwork_gettrackurl host function. +// GetTrackUrl generates a public URL for a track's artwork. +// +// Parameters: +// - id: The track's (media file) unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetTrackUrl(id string, size int32) (*ArtworkGetTrackUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_gettrackurl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetTrackUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetPlaylistUrl calls the artwork_getplaylisturl host function. +// GetPlaylistUrl generates a public URL for a playlist's artwork. +// +// Parameters: +// - id: The playlist's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetPlaylistUrl(id string, size int32) (*ArtworkGetPlaylistUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getplaylisturl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetPlaylistUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go new file mode 100644 index 000000000..b1ce838c8 --- /dev/null +++ b/plugins/host_artwork.go @@ -0,0 +1,92 @@ +package plugins + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strconv" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/host" +) + +type artworkServiceImpl struct{} + +func newArtworkService() host.ArtworkService { + return &artworkServiceImpl{} +} + +func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { + artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} + return a.imageURL(artID, int(size)), nil +} + +func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { + artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} + return a.imageURL(artID, int(size)), nil +} + +func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { + artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} + return a.imageURL(artID, int(size)), nil +} + +func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { + artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} + return a.imageURL(artID, int(size)), nil +} + +// imageURL generates a public URL for artwork, replicating the logic from server/public +// to avoid import cycles. +func (a *artworkServiceImpl) imageURL(artID model.ArtworkID, size int) string { + token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) + uri := path.Join(consts.URLPathPublicImages, token) + params := url.Values{} + if size > 0 { + params.Add("size", strconv.Itoa(size)) + } + return a.publicURL(uri, params) +} + +// publicURL builds the full URL using ShareURL config or falling back to localhost. +func (a *artworkServiceImpl) publicURL(u string, params url.Values) string { + 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" + } + buildURL, _ := url.Parse(u) + buildURL.Scheme = scheme + buildURL.Host = host + if len(params) > 0 { + buildURL.RawQuery = params.Encode() + } + return buildURL.String() +} + +// createRequest creates a dummy HTTP request for URL generation. +// Kept for reference but no longer used after refactoring. +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 +} + +var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go new file mode 100644 index 000000000..f37909c14 --- /dev/null +++ b/plugins/host_artwork_test.go @@ -0,0 +1,219 @@ +//go:build !windows + +package plugins + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ArtworkService", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "artwork-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the fake-artwork plugin + srcPath := filepath.Join(testdataDir, "fake-artwork.wasm") + destPath := filepath.Join(tmpDir, "fake-artwork.wasm") + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + + // Initialize auth (required for token generation) + ds := &tests.MockDataStore{MockedProperty: &tests.MockedPropertyRepo{}} + auth.Init(ds) + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Plugin Loading", func() { + It("should load plugin with artwork permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["fake-artwork"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Artwork).ToNot(BeNil()) + }) + }) + + Describe("Artwork URL Generation", func() { + type testArtworkInput struct { + ArtworkType string `json:"artwork_type"` + ID string `json:"id"` + Size int32 `json:"size"` + } + type testArtworkOutput struct { + URL string `json:"url,omitempty"` + Error *string `json:"error,omitempty"` + } + + callTestArtwork := func(ctx context.Context, artworkType, id string, size int32) (string, error) { + manager.mu.RLock() + p := manager.plugins["fake-artwork"] + manager.mu.RUnlock() + + instance, err := p.instance() + if err != nil { + return "", err + } + defer instance.Close(ctx) + + input := testArtworkInput{ + ArtworkType: artworkType, + ID: id, + Size: size, + } + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_artwork", inputBytes) + if err != nil { + return "", err + } + + var output testArtworkOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return "", err + } + if output.Error != nil { + return "", Errorf(*output.Error) + } + return output.URL, nil + } + + It("should generate artist artwork URL", func() { + url, err := callTestArtwork(GinkgoT().Context(), "artist", "ar-123", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(ContainSubstring("/img/")) + Expect(url).ToNot(ContainSubstring("size=")) + + // Decode JWT and verify artwork ID + artID := decodeArtworkURL(url) + Expect(artID.Kind).To(Equal(model.KindArtistArtwork)) + Expect(artID.ID).To(Equal("ar-123")) + }) + + It("should generate album artwork URL", func() { + url, err := callTestArtwork(GinkgoT().Context(), "album", "al-456", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(ContainSubstring("/img/")) + + artID := decodeArtworkURL(url) + Expect(artID.Kind).To(Equal(model.KindAlbumArtwork)) + Expect(artID.ID).To(Equal("al-456")) + }) + + It("should generate track artwork URL", func() { + url, err := callTestArtwork(GinkgoT().Context(), "track", "mf-789", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(ContainSubstring("/img/")) + + artID := decodeArtworkURL(url) + Expect(artID.Kind).To(Equal(model.KindMediaFileArtwork)) + Expect(artID.ID).To(Equal("mf-789")) + }) + + It("should generate playlist artwork URL", func() { + url, err := callTestArtwork(GinkgoT().Context(), "playlist", "pl-abc", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(ContainSubstring("/img/")) + + artID := decodeArtworkURL(url) + Expect(artID.Kind).To(Equal(model.KindPlaylistArtwork)) + Expect(artID.ID).To(Equal("pl-abc")) + }) + + It("should include size parameter when specified", func() { + url, err := callTestArtwork(GinkgoT().Context(), "album", "al-456", 300) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(ContainSubstring("size=300")) + + artID := decodeArtworkURL(url) + Expect(artID.Kind).To(Equal(model.KindAlbumArtwork)) + Expect(artID.ID).To(Equal("al-456")) + }) + + It("should handle unknown artwork type", func() { + _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown artwork type")) + }) + }) +}) + +// Errorf creates an error from a format string (helper for tests) +func Errorf(format string, args ...any) error { + return &errorString{s: format} +} + +type errorString struct { + s string +} + +func (e *errorString) Error() string { + return e.s +} + +// decodeArtworkURL extracts and decodes the JWT token from an artwork URL, +// returning the parsed ArtworkID. Panics on error (test helper). +func decodeArtworkURL(artworkURL string) model.ArtworkID { + // URL format: http://localhost/img/?size=... + // Extract token from path after /img/ + idx := strings.Index(artworkURL, "/img/") + Expect(idx).To(BeNumerically(">=", 0), "URL should contain /img/") + + tokenPart := artworkURL[idx+5:] // skip "/img/" + // Remove query string if present + if qIdx := strings.Index(tokenPart, "?"); qIdx >= 0 { + tokenPart = tokenPart[:qIdx] + } + + // Decode JWT token + token, err := auth.TokenAuth.Decode(tokenPart) + Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") + + claims, err := token.AsMap(context.Background()) + Expect(err).ToNot(HaveOccurred(), "Failed to get claims from token") + + id, ok := claims["id"].(string) + Expect(ok).To(BeTrue(), "Token should contain 'id' claim") + + artID, err := model.ParseArtworkID(id) + Expect(err).ToNot(HaveOccurred(), "Failed to parse artwork ID from token") + + return artID +} diff --git a/plugins/manager.go b/plugins/manager.go index 09ba92631..6a0ded160 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -362,6 +362,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { stubHostFunctions := host.RegisterSubsonicAPIHostFunctions(nil) stubHostFunctions = append(stubHostFunctions, host.RegisterSchedulerHostFunctions(nil)...) stubHostFunctions = append(stubHostFunctions, host.RegisterWebSocketHostFunctions(nil)...) + stubHostFunctions = append(stubHostFunctions, host.RegisterArtworkHostFunctions(nil)...) // Create initial compiled plugin with stub host functions compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, stubHostFunctions) @@ -433,6 +434,12 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { hostFunctions = append(hostFunctions, host.RegisterWebSocketHostFunctions(service)...) } + // Register Artwork host functions if permission is granted + if manifest.Permissions != nil && manifest.Permissions.Artwork != nil { + service := newArtworkService() + hostFunctions = append(hostFunctions, host.RegisterArtworkHostFunctions(service)...) + } + // Check if recompilation is needed (AllowedHosts or host functions) needsRecompile := len(pluginManifest.AllowedHosts) > 0 || len(hostFunctions) > 0 diff --git a/plugins/manifest.json b/plugins/manifest.json index 86c659008..3969b814f 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -52,6 +52,20 @@ }, "websocket": { "$ref": "#/$defs/WebSocketPermission" + }, + "artwork": { + "$ref": "#/$defs/ArtworkPermission" + } + } + }, + "ArtworkPermission": { + "type": "object", + "description": "Artwork service permissions for generating artwork URLs", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why artwork access is needed" } } }, diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 87795cc37..4f2d8625b 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -5,6 +5,12 @@ package plugins import "encoding/json" import "fmt" +// Artwork service permissions for generating artwork URLs +type ArtworkPermission struct { + // Explanation for why artwork access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Configuration access permissions for a plugin type ConfigPermission struct { // Explanation for why config access is needed @@ -77,6 +83,9 @@ func (j *Manifest) UnmarshalJSON(value []byte) error { // Permissions required by the plugin type Permissions struct { + // Artwork corresponds to the JSON schema field "artwork". + Artwork *ArtworkPermission `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"` + // Http corresponds to the JSON schema field "http". Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` diff --git a/plugins/testdata/fake-artwork/go.mod b/plugins/testdata/fake-artwork/go.mod new file mode 100644 index 000000000..79b9bb5a7 --- /dev/null +++ b/plugins/testdata/fake-artwork/go.mod @@ -0,0 +1,5 @@ +module fake-artwork + +go 1.23 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/testdata/fake-artwork/go.sum b/plugins/testdata/fake-artwork/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/plugins/testdata/fake-artwork/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/testdata/fake-artwork/main.go b/plugins/testdata/fake-artwork/main.go new file mode 100644 index 000000000..b7ec1897b --- /dev/null +++ b/plugins/testdata/fake-artwork/main.go @@ -0,0 +1,139 @@ +// Fake Artwork plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../fake-artwork.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "encoding/json" + "strings" + + pdk "github.com/extism/go-pdk" +) + +// Manifest types +type Manifest struct { + Name string `json:"name"` + Author string `json:"author"` + Version string `json:"version"` + Description string `json:"description"` + Permissions *Permissions `json:"permissions,omitempty"` +} + +type Permissions struct { + Artwork *ArtworkPermission `json:"artwork,omitempty"` +} + +type ArtworkPermission struct { + Reason string `json:"reason,omitempty"` +} + +//go:wasmexport nd_manifest +func ndManifest() int32 { + manifest := Manifest{ + Name: "Fake Artwork", + Author: "Navidrome Test", + Version: "1.0.0", + Description: "A fake artwork plugin for integration testing", + Permissions: &Permissions{ + Artwork: &ArtworkPermission{ + Reason: "For testing artwork URL generation", + }, + }, + } + out, err := json.Marshal(manifest) + if err != nil { + pdk.SetError(err) + return 1 + } + pdk.Output(out) + return 0 +} + +// TestInput is the input for nd_test_artwork callback. +type TestInput struct { + ArtworkType string `json:"artwork_type"` // "artist", "album", "track", "playlist" + ID string `json:"id"` + Size int32 `json:"size"` +} + +// TestOutput is the output from nd_test_artwork callback. +type TestOutput struct { + URL string `json:"url,omitempty"` + Error *string `json:"error,omitempty"` +} + +// nd_test_artwork is the test callback that tests the artwork host functions. +// +//go:wasmexport nd_test_artwork +func ndTestArtwork() int32 { + var input TestInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + + var url string + var err error + + switch strings.ToLower(input.ArtworkType) { + case "artist": + resp, e := ArtworkGetArtistUrl(input.ID, input.Size) + if e != nil { + err = e + } else if resp.Error != "" { + errStr := resp.Error + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } else { + url = resp.Url + } + case "album": + resp, e := ArtworkGetAlbumUrl(input.ID, input.Size) + if e != nil { + err = e + } else if resp.Error != "" { + errStr := resp.Error + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } else { + url = resp.Url + } + case "track": + resp, e := ArtworkGetTrackUrl(input.ID, input.Size) + if e != nil { + err = e + } else if resp.Error != "" { + errStr := resp.Error + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } else { + url = resp.Url + } + case "playlist": + resp, e := ArtworkGetPlaylistUrl(input.ID, input.Size) + if e != nil { + err = e + } else if resp.Error != "" { + errStr := resp.Error + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } else { + url = resp.Url + } + default: + errStr := "unknown artwork type: " + input.ArtworkType + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestOutput{Error: &errStr}) + return 0 + } + + pdk.OutputJSON(TestOutput{URL: url}) + return 0 +} + +func main() {} diff --git a/plugins/testdata/fake-artwork/nd_host_artwork.go b/plugins/testdata/fake-artwork/nd_host_artwork.go new file mode 100644 index 000000000..0943c794c --- /dev/null +++ b/plugins/testdata/fake-artwork/nd_host_artwork.go @@ -0,0 +1,170 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Artwork host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + + "github.com/extism/go-pdk" +) + +// artwork_getartisturl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getartisturl +func artwork_getartisturl(uint64, int32) uint64 + +// artwork_getalbumurl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getalbumurl +func artwork_getalbumurl(uint64, int32) uint64 + +// artwork_gettrackurl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_gettrackurl +func artwork_gettrackurl(uint64, int32) uint64 + +// artwork_getplaylisturl is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user artwork_getplaylisturl +func artwork_getplaylisturl(uint64, int32) uint64 + +// ArtworkGetArtistUrlResponse is the response type for Artwork.GetArtistUrl. +type ArtworkGetArtistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetAlbumUrlResponse is the response type for Artwork.GetAlbumUrl. +type ArtworkGetAlbumUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetTrackUrlResponse is the response type for Artwork.GetTrackUrl. +type ArtworkGetTrackUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetPlaylistUrlResponse is the response type for Artwork.GetPlaylistUrl. +type ArtworkGetPlaylistUrlResponse struct { + Url string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// ArtworkGetArtistUrl calls the artwork_getartisturl host function. +// GetArtistUrl generates a public URL for an artist's artwork. +// +// Parameters: +// - id: The artist's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetArtistUrl(id string, size int32) (*ArtworkGetArtistUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getartisturl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetArtistUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetAlbumUrl calls the artwork_getalbumurl host function. +// GetAlbumUrl generates a public URL for an album's artwork. +// +// Parameters: +// - id: The album's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetAlbumUrl(id string, size int32) (*ArtworkGetAlbumUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getalbumurl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetAlbumUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetTrackUrl calls the artwork_gettrackurl host function. +// GetTrackUrl generates a public URL for a track's artwork. +// +// Parameters: +// - id: The track's (media file) unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetTrackUrl(id string, size int32) (*ArtworkGetTrackUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_gettrackurl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetTrackUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// ArtworkGetPlaylistUrl calls the artwork_getplaylisturl host function. +// GetPlaylistUrl generates a public URL for a playlist's artwork. +// +// Parameters: +// - id: The playlist's unique identifier +// - size: Desired image size in pixels (0 for original size) +// +// Returns the public URL for the artwork, or an error if generation fails. +func ArtworkGetPlaylistUrl(id string, size int32) (*ArtworkGetPlaylistUrlResponse, error) { + idMem := pdk.AllocateString(id) + defer idMem.Free() + + // Call the host function + responsePtr := artwork_getplaylisturl(idMem.Offset(), size) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response ArtworkGetPlaylistUrlResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +}