mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-03 06:15:22 +00:00
feat: implement Artwork service for generating artwork URLs in Navidrome plugins - WIP
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
e60efde4d4
commit
a78bbca741
53
plugins/host/artwork.go
Normal file
53
plugins/host/artwork.go
Normal 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
182
plugins/host/artwork_gen.go
Normal 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
|
||||
}
|
||||
170
plugins/host/go/nd_host_artwork.go
Normal file
170
plugins/host/go/nd_host_artwork.go
Normal 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
92
plugins/host_artwork.go
Normal 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)
|
||||
219
plugins/host_artwork_test.go
Normal file
219
plugins/host_artwork_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
5
plugins/testdata/fake-artwork/go.mod
vendored
Normal 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
2
plugins/testdata/fake-artwork/go.sum
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
139
plugins/testdata/fake-artwork/main.go
vendored
Normal file
139
plugins/testdata/fake-artwork/main.go
vendored
Normal 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() {}
|
||||
170
plugins/testdata/fake-artwork/nd_host_artwork.go
vendored
Normal file
170
plugins/testdata/fake-artwork/nd_host_artwork.go
vendored
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user