feat: implement Artwork service for generating artwork URLs in Navidrome plugins - WIP

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-25 19:44:22 -05:00
parent e60efde4d4
commit a78bbca741
12 changed files with 1062 additions and 0 deletions

53
plugins/host/artwork.go Normal file
View File

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

182
plugins/host/artwork_gen.go Normal file
View File

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

View File

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

92
plugins/host_artwork.go Normal file
View File

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

View File

@ -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/<token>?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
}

View File

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

View File

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

View File

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

5
plugins/testdata/fake-artwork/go.mod vendored Normal file
View File

@ -0,0 +1,5 @@
module fake-artwork
go 1.23
require github.com/extism/go-pdk v1.1.3

2
plugins/testdata/fake-artwork/go.sum vendored Normal file
View File

@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

139
plugins/testdata/fake-artwork/main.go vendored Normal file
View File

@ -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() {}

View File

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