mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-03 14:27:35 -05:00
Compare commits
22 Commits
go1.26
...
update-tra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8640852daa | ||
|
|
c885766854 | ||
|
|
692f0f99f6 | ||
|
|
157c917ca5 | ||
|
|
435fb0b076 | ||
|
|
6fd044fb09 | ||
|
|
30df004d4d | ||
|
|
82f9f88c0f | ||
|
|
3d86d44fd9 | ||
|
|
acd69f6a4f | ||
|
|
c4fd8e3125 | ||
|
|
27a83547f7 | ||
|
|
d004f99f8f | ||
|
|
4e34d3ac1f | ||
|
|
3476be01f7 | ||
|
|
2471bb9cf6 | ||
|
|
d9a215e1e3 | ||
|
|
d134de1061 | ||
|
|
bd8032b327 | ||
|
|
582d1b3cd9 | ||
|
|
cdd3432788 | ||
|
|
5bc2bbb70e |
20
.github/workflows/pipeline.yml
vendored
20
.github/workflows/pipeline.yml
vendored
@@ -235,7 +235,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -322,7 +322,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -374,7 +374,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -393,7 +393,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v7
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -460,13 +460,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
@@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
||||
expiresAt := token.Expiration()
|
||||
if expiresAt.IsZero() {
|
||||
expiresAt, ok := token.Expiration()
|
||||
if !ok || expiresAt.IsZero() {
|
||||
return "", errors.New("deezer: JWT token has no expiration time")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -179,7 +179,8 @@ var _ = Describe("JWT Authentication", func() {
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
// Verify token has no expiration
|
||||
Expect(testToken.Expiration().IsZero()).To(BeTrue())
|
||||
_, hasExp := testToken.Expiration()
|
||||
Expect(hasExp).To(BeFalse())
|
||||
|
||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
@@ -46,6 +46,7 @@ type configOptions struct {
|
||||
EnableTranscodingCancellation bool
|
||||
EnableDownloads bool
|
||||
EnableExternalServices bool
|
||||
EnableM3UExternalAlbumArt bool
|
||||
EnableInsightsCollector bool
|
||||
EnableMediaFileCoverArt bool
|
||||
TranscodingCacheSize string
|
||||
@@ -75,6 +76,7 @@ type configOptions struct {
|
||||
EnableFavourites bool
|
||||
EnableStarRating bool
|
||||
EnableUserEditing bool
|
||||
EnableCoverArtUpload bool
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
@@ -155,6 +157,7 @@ type scannerOptions struct {
|
||||
|
||||
type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
AppendAlbumVersion bool
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
EnableAverageRating bool
|
||||
@@ -302,6 +305,12 @@ func Load(noConfigDump bool) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if Server.Plugins.Enabled {
|
||||
if Server.Plugins.Folder == "" {
|
||||
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||
@@ -467,6 +476,7 @@ func parseIniFileConfiguration() {
|
||||
func disableExternalServices() {
|
||||
log.Info("All external integrations are DISABLED!")
|
||||
Server.EnableInsightsCollector = false
|
||||
Server.EnableM3UExternalAlbumArt = false
|
||||
Server.LastFM.Enabled = false
|
||||
Server.Spotify.ID = ""
|
||||
Server.Deezer.Enabled = false
|
||||
@@ -631,6 +641,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enablem3uexternalalbumart", false)
|
||||
viper.SetDefault("enablemediafilecoverart", true)
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
@@ -658,6 +669,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("enablereplaygain", true)
|
||||
viper.SetDefault("enablecoveranimation", true)
|
||||
viper.SetDefault("enablenowplaying", true)
|
||||
viper.SetDefault("enablecoverartupload", true)
|
||||
viper.SetDefault("enablesharing", false)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
@@ -689,6 +701,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("scanner.followsymlinks", true)
|
||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
|
||||
@@ -65,6 +65,7 @@ const (
|
||||
|
||||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
|
||||
@@ -235,6 +235,113 @@ var _ = Describe("Artwork", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Describe("playlistArtworkReader", func() {
|
||||
Describe("findPlaylistSidecarPath", func() {
|
||||
It("discovers sidecar image next to playlist file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("returns empty string when no sidecar image exists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty string when playlist has no path", func() {
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), "")
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("finds sidecar with different case base name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
plsPath := filepath.Join(tmpDir, "myplaylist.m3u")
|
||||
imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg")
|
||||
Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath)
|
||||
Expect(result).To(Equal(imgPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("fromPlaylistExternalImage", func() {
|
||||
It("opens local path from ExternalImageURL", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
data, _ := io.ReadAll(r)
|
||||
Expect(string(data)).To(Equal("external image data"))
|
||||
r.Close()
|
||||
})
|
||||
|
||||
It("returns nil when ExternalImageURL is empty", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: ""},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when local file does not exist", func() {
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"},
|
||||
}
|
||||
r, _, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
})
|
||||
|
||||
It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).To(BeNil())
|
||||
Expect(path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("still opens local path when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed())
|
||||
|
||||
reader := &playlistArtworkReader{
|
||||
pl: model.Playlist{ExternalImageURL: imgPath},
|
||||
}
|
||||
r, path, err := reader.fromPlaylistExternalImage(ctx)()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
Expect(path).To(Equal(imgPath))
|
||||
r.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resizedArtworkReader", func() {
|
||||
BeforeEach(func() {
|
||||
folderRepo.result = []model.Folder{{
|
||||
|
||||
@@ -8,9 +8,14 @@ import (
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -35,6 +40,24 @@ func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model
|
||||
}
|
||||
a.cacheKey.artID = artID
|
||||
a.cacheKey.lastUpdate = pl.UpdatedAt
|
||||
|
||||
// Check sidecar and ExternalImageURL local file ModTimes for cache invalidation.
|
||||
// If either is newer than the playlist's UpdatedAt, use that instead so the
|
||||
// cache is busted when a user replaces a sidecar image or local file reference.
|
||||
for _, path := range []string{
|
||||
findPlaylistSidecarPath(ctx, pl.Path),
|
||||
pl.ExternalImageURL,
|
||||
} {
|
||||
if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil {
|
||||
if info.ModTime().After(a.cacheKey.lastUpdate) {
|
||||
a.cacheKey.lastUpdate = info.ModTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -43,11 +66,81 @@ func (a *playlistArtworkReader) LastUpdated() time.Time {
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
|
||||
ff := []sourceFunc{
|
||||
return selectImageReader(ctx, a.artID,
|
||||
a.fromPlaylistUploadedImage(),
|
||||
a.fromPlaylistSidecar(ctx),
|
||||
a.fromPlaylistExternalImage(ctx),
|
||||
a.fromGeneratedTiledCover(ctx),
|
||||
fromAlbumPlaceholder(),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc {
|
||||
return fromLocalFile(a.pl.UploadedImagePath())
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc {
|
||||
return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path))
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imgURL := a.pl.ExternalImageURL
|
||||
if imgURL == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
parsed, err := url.Parse(imgURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if parsed.Scheme == "http" || parsed.Scheme == "https" {
|
||||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||||
return nil, "", nil
|
||||
}
|
||||
return fromURL(ctx, parsed)
|
||||
}
|
||||
return fromLocalFile(imgURL)()
|
||||
}
|
||||
return selectImageReader(ctx, a.artID, ff...)
|
||||
}
|
||||
|
||||
// fromLocalFile returns a sourceFunc that opens the given local path.
|
||||
// Returns (nil, "", nil) if path is empty — signalling "not found, try next source".
|
||||
func fromLocalFile(path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return f, path, nil
|
||||
}
|
||||
}
|
||||
|
||||
// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar
|
||||
// image file with the same base name (case-insensitive). Returns empty string if
|
||||
// no matching image is found or if plsPath is empty.
|
||||
func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
|
||||
if plsPath == "" {
|
||||
return ""
|
||||
}
|
||||
dir := filepath.Dir(plsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err)
|
||||
return ""
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
nameBase := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) {
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
|
||||
|
||||
@@ -4,12 +4,11 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -46,38 +45,30 @@ func Init(ds model.DataStore) {
|
||||
})
|
||||
}
|
||||
|
||||
func createBaseClaims() map[string]any {
|
||||
tokenClaims := map[string]any{}
|
||||
tokenClaims[jwt.IssuerKey] = consts.JWTIssuer
|
||||
return tokenClaims
|
||||
}
|
||||
|
||||
func CreatePublicToken(claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
maps.Copy(tokenClaims, claims)
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
func CreatePublicToken(claims Claims) (string, error) {
|
||||
claims.Issuer = consts.JWTIssuer
|
||||
_, token, err := TokenAuth.Encode(claims.ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
|
||||
claims.Issuer = consts.JWTIssuer
|
||||
if !exp.IsZero() {
|
||||
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
|
||||
claims.ExpiresAt = exp
|
||||
}
|
||||
maps.Copy(tokenClaims, claims)
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
_, token, err := TokenAuth.Encode(claims.ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
claims := createBaseClaims()
|
||||
claims[jwt.SubjectKey] = u.UserName
|
||||
claims[jwt.IssuedAtKey] = time.Now().UTC().Unix()
|
||||
claims["uid"] = u.ID
|
||||
claims["adm"] = u.IsAdmin
|
||||
token, _, err := TokenAuth.Encode(claims)
|
||||
claims := Claims{
|
||||
Issuer: consts.JWTIssuer,
|
||||
Subject: u.UserName,
|
||||
IssuedAt: time.Now(),
|
||||
UserID: u.ID,
|
||||
IsAdmin: u.IsAdmin,
|
||||
}
|
||||
token, _, err := TokenAuth.Encode(claims.ToMap())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -86,23 +77,18 @@ func CreateToken(u *model.User) (string, error) {
|
||||
}
|
||||
|
||||
func TouchToken(token jwt.Token) (string, error) {
|
||||
claims, err := token.AsMap(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix()
|
||||
_, newToken, err := TokenAuth.Encode(claims)
|
||||
|
||||
claims := ClaimsFromToken(token).
|
||||
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
|
||||
_, newToken, err := TokenAuth.Encode(claims.ToMap())
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (map[string]any, error) {
|
||||
func Validate(tokenStr string) (Claims, error) {
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Claims{}, err
|
||||
}
|
||||
return token.AsMap(context.Background())
|
||||
return ClaimsFromToken(token), nil
|
||||
}
|
||||
|
||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
|
||||
@@ -54,7 +54,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
decodedClaims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(decodedClaims["iss"]).To(Equal("issuer"))
|
||||
Expect(decodedClaims.Issuer).To(Equal("issuer"))
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
@@ -82,11 +82,11 @@ var _ = Describe("Auth", func() {
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(claims["iss"]).To(Equal(consts.JWTIssuer))
|
||||
Expect(claims["sub"]).To(Equal("johndoe"))
|
||||
Expect(claims["uid"]).To(Equal("123"))
|
||||
Expect(claims["adm"]).To(Equal(true))
|
||||
Expect(claims["exp"]).To(BeTemporally(">", time.Now()))
|
||||
Expect(claims.Issuer).To(Equal(consts.JWTIssuer))
|
||||
Expect(claims.Subject).To(Equal("johndoe"))
|
||||
Expect(claims.UserID).To(Equal("123"))
|
||||
Expect(claims.IsAdmin).To(Equal(true))
|
||||
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,8 +104,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
decodedClaims, err := auth.Validate(touched)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
exp := decodedClaims["exp"].(time.Time)
|
||||
Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
94
core/auth/claims.go
Normal file
94
core/auth/claims.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
)
|
||||
|
||||
// Claims represents the typed JWT claims used throughout Navidrome,
|
||||
// replacing the untyped map[string]any approach.
|
||||
type Claims struct {
|
||||
// Standard JWT claims
|
||||
Issuer string
|
||||
Subject string // username for session tokens
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
|
||||
// Custom claims
|
||||
UserID string // "uid"
|
||||
IsAdmin bool // "adm"
|
||||
ID string // "id" - artwork/mediafile ID
|
||||
Format string // "f" - audio format
|
||||
BitRate int // "b" - audio bitrate
|
||||
}
|
||||
|
||||
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
|
||||
// Only non-zero fields are included.
|
||||
func (c Claims) ToMap() map[string]any {
|
||||
m := make(map[string]any)
|
||||
if c.Issuer != "" {
|
||||
m[jwt.IssuerKey] = c.Issuer
|
||||
}
|
||||
if c.Subject != "" {
|
||||
m[jwt.SubjectKey] = c.Subject
|
||||
}
|
||||
if !c.IssuedAt.IsZero() {
|
||||
m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
|
||||
}
|
||||
if !c.ExpiresAt.IsZero() {
|
||||
m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix()
|
||||
}
|
||||
if c.UserID != "" {
|
||||
m["uid"] = c.UserID
|
||||
}
|
||||
if c.IsAdmin {
|
||||
m["adm"] = c.IsAdmin
|
||||
}
|
||||
if c.ID != "" {
|
||||
m["id"] = c.ID
|
||||
}
|
||||
if c.Format != "" {
|
||||
m["f"] = c.Format
|
||||
}
|
||||
if c.BitRate != 0 {
|
||||
m["b"] = c.BitRate
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (c Claims) WithExpiresAt(t time.Time) Claims {
|
||||
c.ExpiresAt = t
|
||||
return c
|
||||
}
|
||||
|
||||
// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get().
|
||||
func ClaimsFromToken(token jwt.Token) Claims {
|
||||
var c Claims
|
||||
c.Issuer, _ = token.Issuer()
|
||||
c.Subject, _ = token.Subject()
|
||||
c.IssuedAt, _ = token.IssuedAt()
|
||||
c.ExpiresAt, _ = token.Expiration()
|
||||
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err == nil {
|
||||
c.UserID = uid
|
||||
}
|
||||
var adm bool
|
||||
if err := token.Get("adm", &adm); err == nil {
|
||||
c.IsAdmin = adm
|
||||
}
|
||||
var id string
|
||||
if err := token.Get("id", &id); err == nil {
|
||||
c.ID = id
|
||||
}
|
||||
var f string
|
||||
if err := token.Get("f", &f); err == nil {
|
||||
c.Format = f
|
||||
}
|
||||
var b int
|
||||
if err := token.Get("b", &b); err == nil {
|
||||
c.BitRate = b
|
||||
}
|
||||
return c
|
||||
}
|
||||
99
core/auth/claims_test.go
Normal file
99
core/auth/claims_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Claims", func() {
|
||||
Describe("ToMap", func() {
|
||||
It("includes only non-zero fields", func() {
|
||||
c := auth.Claims{
|
||||
Issuer: "ND",
|
||||
Subject: "johndoe",
|
||||
UserID: "123",
|
||||
IsAdmin: true,
|
||||
}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKeyWithValue("iss", "ND"))
|
||||
Expect(m).To(HaveKeyWithValue("sub", "johndoe"))
|
||||
Expect(m).To(HaveKeyWithValue("uid", "123"))
|
||||
Expect(m).To(HaveKeyWithValue("adm", true))
|
||||
Expect(m).NotTo(HaveKey("exp"))
|
||||
Expect(m).NotTo(HaveKey("iat"))
|
||||
Expect(m).NotTo(HaveKey("id"))
|
||||
Expect(m).NotTo(HaveKey("f"))
|
||||
Expect(m).NotTo(HaveKey("b"))
|
||||
})
|
||||
|
||||
It("includes expiration and issued-at when set", func() {
|
||||
now := time.Now()
|
||||
c := auth.Claims{
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKey("iat"))
|
||||
Expect(m).To(HaveKey("exp"))
|
||||
})
|
||||
|
||||
It("includes custom claims for public tokens", func() {
|
||||
c := auth.Claims{
|
||||
ID: "al-123",
|
||||
Format: "mp3",
|
||||
BitRate: 192,
|
||||
}
|
||||
m := c.ToMap()
|
||||
Expect(m).To(HaveKeyWithValue("id", "al-123"))
|
||||
Expect(m).To(HaveKeyWithValue("f", "mp3"))
|
||||
Expect(m).To(HaveKeyWithValue("b", 192))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClaimsFromToken", func() {
|
||||
It("round-trips session claims through encode/decode", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
now := time.Now().Truncate(time.Second)
|
||||
original := auth.Claims{
|
||||
Issuer: "ND",
|
||||
Subject: "johndoe",
|
||||
UserID: "123",
|
||||
IsAdmin: true,
|
||||
}
|
||||
m := original.ToMap()
|
||||
m["iat"] = now.UTC().Unix()
|
||||
token, _, err := tokenAuth.Encode(m)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
c := auth.ClaimsFromToken(token)
|
||||
Expect(c.Issuer).To(Equal("ND"))
|
||||
Expect(c.Subject).To(Equal("johndoe"))
|
||||
Expect(c.UserID).To(Equal("123"))
|
||||
Expect(c.IsAdmin).To(BeTrue())
|
||||
Expect(c.IssuedAt.UTC()).To(Equal(now.UTC()))
|
||||
})
|
||||
|
||||
It("round-trips public token claims through encode/decode", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
original := auth.Claims{
|
||||
Issuer: "ND",
|
||||
ID: "al-456",
|
||||
Format: "opus",
|
||||
BitRate: 128,
|
||||
}
|
||||
token, _, err := tokenAuth.Encode(original.ToMap())
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
c := auth.ClaimsFromToken(token)
|
||||
Expect(c.Issuer).To(Equal("ND"))
|
||||
Expect(c.ID).To(Equal("al-456"))
|
||||
Expect(c.Format).To(Equal("opus"))
|
||||
Expect(c.BitRate).To(Equal(128))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -106,6 +106,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
|
||||
@@ -2,7 +2,9 @@ package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -39,6 +41,7 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
@@ -93,6 +96,213 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with HTTP URL", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
|
||||
pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with absolute local path", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath)
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with relative local path", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg")))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with file:// URL", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "my cover.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20"))
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
imgPath := filepath.Join(tmpDir, "A+B.jpg")
|
||||
Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed())
|
||||
|
||||
m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath)
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal(imgPath))
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with absolute path outside library boundaries", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:/etc/passwd\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:file:///etc/passwd\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects #EXTALBUMARTURL with relative path escaping library", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
|
||||
m3u := "#EXTALBUMARTURL:../../etc/passwd\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "existing-id",
|
||||
Name: "Existing Playlist",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
UploadedImage: "existing-id.jpg",
|
||||
ExternalImageURL: "https://example.com/old-cover.jpg",
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.UploadedImage).To(Equal("existing-id.jpg"))
|
||||
Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg"))
|
||||
})
|
||||
|
||||
It("clears ExternalImageURL on re-scan when directive is removed", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
m3u := "test.mp3\n"
|
||||
plsFile := filepath.Join(tmpDir, "test.m3u")
|
||||
Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed())
|
||||
|
||||
existingPls := &model.Playlist{
|
||||
ID: "existing-id",
|
||||
Name: "Existing Playlist",
|
||||
Path: plsFile,
|
||||
Sync: true,
|
||||
ExternalImageURL: "https://example.com/old-cover.jpg",
|
||||
}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
|
||||
|
||||
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NSP", func() {
|
||||
@@ -125,7 +335,6 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
@@ -495,6 +704,24 @@ var _ = Describe("Playlists - Import", func() {
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||
})
|
||||
|
||||
It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
|
||||
repo.data = []string{"tests/test.mp3"}
|
||||
m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n"
|
||||
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg"))
|
||||
})
|
||||
|
||||
It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() {
|
||||
repo.data = []string{"tests/test.mp3"}
|
||||
m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n"
|
||||
pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ExternalImageURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
|
||||
// so we need exact matching for non-ASCII characters.
|
||||
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -34,13 +35,17 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
pls.Name = line[len("#PLAYLIST:"):]
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok {
|
||||
pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher)
|
||||
continue
|
||||
}
|
||||
// Skip empty lines and extended info
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "file://"); ok {
|
||||
line = after
|
||||
line, _ = url.QueryUnescape(line)
|
||||
line, _ = url.PathUnescape(line)
|
||||
}
|
||||
if !model.IsAudioFile(line) {
|
||||
continue
|
||||
@@ -267,3 +272,53 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string.
|
||||
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
|
||||
// Local paths (file://, absolute, or relative) are resolved to an absolute path
|
||||
// and validated against known library boundaries via matcher.
|
||||
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// HTTP(S) URLs — store as-is, but only if external album art is enabled
|
||||
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
||||
if !conf.Server.EnableM3UExternalAlbumArt {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Resolve to local absolute path
|
||||
localPath, ok := resolveLocalPath(value, folder)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Validate path is within a known library
|
||||
if libID, _ := matcher.findLibraryForPath(localPath); libID == 0 {
|
||||
return ""
|
||||
}
|
||||
return localPath
|
||||
}
|
||||
|
||||
// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path.
|
||||
// Returns ("", false) if the path cannot be resolved.
|
||||
func resolveLocalPath(value string, folder *model.Folder) (string, bool) {
|
||||
if after, ok := strings.CutPrefix(value, "file://"); ok {
|
||||
decoded, err := url.PathUnescape(after)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(decoded), true
|
||||
}
|
||||
if filepath.IsAbs(value) {
|
||||
return filepath.Clean(value), true
|
||||
}
|
||||
if folder == nil {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
@@ -34,6 +37,10 @@ type Playlists interface {
|
||||
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
|
||||
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
|
||||
|
||||
// Cover art
|
||||
SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
|
||||
RemoveImage(ctx context.Context, playlistID string) error
|
||||
|
||||
// Import
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
@@ -118,9 +125,18 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
|
||||
}
|
||||
|
||||
func (s *playlists) Delete(ctx context.Context, id string) error {
|
||||
if _, err := s.checkWritable(ctx, id); err != nil {
|
||||
pls, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up custom cover image file if one exists
|
||||
if path := pls.UploadedImagePath(); path != "" {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.ds.Playlist(ctx).Delete(id)
|
||||
}
|
||||
|
||||
@@ -263,3 +279,57 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
|
||||
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Cover art operations ---
|
||||
|
||||
func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filename := pls.ImageFilename(ext)
|
||||
oldPath := pls.UploadedImagePath()
|
||||
pls.UploadedImage = filename
|
||||
absPath := pls.UploadedImagePath()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return fmt.Errorf("creating playlist images directory: %w", err)
|
||||
}
|
||||
|
||||
// Remove old image if it exists
|
||||
if oldPath != "" {
|
||||
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save new image
|
||||
f, err := os.Create(absPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating playlist image file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, reader); err != nil {
|
||||
return fmt.Errorf("writing playlist image file: %w", err)
|
||||
}
|
||||
|
||||
return s.ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
|
||||
pls, err := s.checkWritable(ctx, playlistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if path := pls.UploadedImagePath(); path != "" {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Warn(ctx, "Failed to remove playlist image", "path", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
pls.UploadedImage = ""
|
||||
return s.ds.Playlist(ctx).Put(pls)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@@ -294,4 +299,119 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetImage", func() {
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("saves image file and updates UploadedImage", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
reader := strings.NewReader("fake image data")
|
||||
err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg"))
|
||||
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
|
||||
data, err := os.ReadFile(absPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(data)).To(Equal("fake image data"))
|
||||
})
|
||||
|
||||
It("removes old image when replacing", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
|
||||
// Upload first image
|
||||
err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png")
|
||||
Expect(oldPath).To(BeAnExistingFile())
|
||||
|
||||
// Upload replacement image
|
||||
err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(oldPath).ToNot(BeAnExistingFile())
|
||||
newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg")
|
||||
Expect(newPath).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("allows admin to set image on any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveImage", func() {
|
||||
var tmpDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tmpDir = GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
// Create a real image file on disk
|
||||
imgDir := filepath.Join(tmpDir, "artwork", "playlist")
|
||||
Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())
|
||||
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"},
|
||||
"pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("removes file and clears UploadedImage", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
|
||||
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
|
||||
Expect(absPath).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("succeeds even if playlist has no image", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "pls-empty")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "pls-1")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveImage(ctx, "nonexistent")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// ImageURL generates a public URL for artwork images.
|
||||
// It creates a signed token for the artwork ID and builds a complete public URL.
|
||||
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
|
||||
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
|
||||
token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()})
|
||||
uri := path.Join(consts.URLPathPublicImages, token)
|
||||
params := url.Values{}
|
||||
if size > 0 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE plugin DROP COLUMN allow_write_access;
|
||||
22
db/migrations/20260228172956_add_playlist_image_file.go
Normal file
22
db/migrations/20260228172956_add_playlist_image_file.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile)
|
||||
}
|
||||
|
||||
func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`)
|
||||
return err
|
||||
}
|
||||
30
db/migrations/20260302021413_rename_playlist_image_fields.go
Normal file
30
db/migrations/20260302021413_rename_playlist_image_fields.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upRenamePlaylistImageFields, downRenamePlaylistImageFields)
|
||||
}
|
||||
|
||||
func upRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN image_file TO uploaded_image;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN external_image_url VARCHAR(255) DEFAULT '';`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN external_image_url;`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN uploaded_image TO image_file;`)
|
||||
return err
|
||||
}
|
||||
16
go.mod
16
go.mod
@@ -31,7 +31,7 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0
|
||||
github.com/go-viper/encoding/ini v0.1.1
|
||||
github.com/gohugoio/hashstructure v0.6.0
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||
@@ -43,7 +43,7 @@ require (
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
@@ -69,7 +69,7 @@ require (
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/term v0.40.0
|
||||
@@ -98,7 +98,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -109,10 +109,11 @@ require (
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -134,6 +135,7 @@ require (
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
||||
32
go.sum
32
go.sum
@@ -83,8 +83,8 @@ github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI=
|
||||
github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
@@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -163,16 +163,18 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
|
||||
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
@@ -296,6 +298,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
|
||||
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
@@ -344,8 +348,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
)
|
||||
|
||||
@@ -70,6 +73,13 @@ func (a Album) CoverArtID() ArtworkID {
|
||||
return artworkIDFromAlbum(a)
|
||||
}
|
||||
|
||||
func (a Album) FullName() string {
|
||||
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
|
||||
}
|
||||
return a.Name
|
||||
}
|
||||
|
||||
// Equals compares two Album structs, ignoring calculated fields
|
||||
func (a Album) Equals(other Album) bool {
|
||||
// Normalize float32 values to avoid false negatives
|
||||
|
||||
@@ -3,11 +3,30 @@ package model_test
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
DescribeTable("FullName",
|
||||
func(enabled bool, tags Tags, expected string) {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = enabled
|
||||
a := Album{Name: "Album", Tags: tags}
|
||||
Expect(a.FullName()).To(Equal(expected))
|
||||
},
|
||||
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"),
|
||||
Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"),
|
||||
Entry("returns just name when tag is absent", true, Tags{}, "Album"),
|
||||
Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("Albums", func() {
|
||||
var albums Albums
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"daterated": {field: "annotation.rated_at"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"averagerating": {field: "media_file.average_rating", numeric: true},
|
||||
"albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation},
|
||||
"albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation},
|
||||
"albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation},
|
||||
|
||||
@@ -95,12 +95,19 @@ type MediaFile struct {
|
||||
}
|
||||
|
||||
func (mf MediaFile) FullTitle() string {
|
||||
if conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil {
|
||||
if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0])
|
||||
}
|
||||
return mf.Title
|
||||
}
|
||||
|
||||
func (mf MediaFile) FullAlbumName() string {
|
||||
if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 {
|
||||
return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0])
|
||||
}
|
||||
return mf.Album
|
||||
}
|
||||
|
||||
func (mf MediaFile) ContentType() string {
|
||||
return mime.TypeByExtension("." + mf.Suffix)
|
||||
}
|
||||
|
||||
@@ -475,7 +475,29 @@ var _ = Describe("MediaFile", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
})
|
||||
Describe(".CoverArtId()", func() {
|
||||
DescribeTable("FullTitle",
|
||||
func(enabled bool, tags Tags, expected string) {
|
||||
conf.Server.Subsonic.AppendSubtitle = enabled
|
||||
mf := MediaFile{Title: "Song", Tags: tags}
|
||||
Expect(mf.FullTitle()).To(Equal(expected))
|
||||
},
|
||||
Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"),
|
||||
Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"),
|
||||
Entry("returns just title when tag is absent", true, Tags{}, "Song"),
|
||||
Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"),
|
||||
)
|
||||
DescribeTable("FullAlbumName",
|
||||
func(enabled bool, tags Tags, expected string) {
|
||||
conf.Server.Subsonic.AppendAlbumVersion = enabled
|
||||
mf := MediaFile{Album: "Album", Tags: tags}
|
||||
Expect(mf.FullAlbumName()).To(Equal(expected))
|
||||
},
|
||||
Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"),
|
||||
Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"),
|
||||
Entry("returns just album name when tag is absent", true, Tags{}, "Album"),
|
||||
Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"),
|
||||
)
|
||||
Describe("CoverArtId()", func() {
|
||||
It("returns its own id if it HasCoverArt", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
)
|
||||
|
||||
type Playlist struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
OwnerName string `structs:"-" json:"ownerName"`
|
||||
OwnerID string `structs:"owner_id" json:"ownerId"`
|
||||
Public bool `structs:"public" json:"public"`
|
||||
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Sync bool `structs:"sync" json:"sync"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Comment string `structs:"comment" json:"comment"`
|
||||
Duration float32 `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
SongCount int `structs:"song_count" json:"songCount"`
|
||||
OwnerName string `structs:"-" json:"ownerName"`
|
||||
OwnerID string `structs:"owner_id" json:"ownerId"`
|
||||
Public bool `structs:"public" json:"public"`
|
||||
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Sync bool `structs:"sync" json:"sync"`
|
||||
UploadedImage string `structs:"uploaded_image" json:"uploadedImage"`
|
||||
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// SmartPlaylist attributes
|
||||
Rules *criteria.Criteria `structs:"rules" json:"rules"`
|
||||
@@ -102,10 +108,31 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
|
||||
pls.refreshStats()
|
||||
}
|
||||
|
||||
// ImageFilename returns a human-friendly filename for an uploaded playlist cover image.
|
||||
// Format: <ID>_<clean_name><ext>, falling back to <ID><ext> if the name cleans to empty.
|
||||
func (pls Playlist) ImageFilename(ext string) string {
|
||||
clean := utils.CleanFileName(pls.Name)
|
||||
if clean == "" {
|
||||
return pls.ID + ext
|
||||
}
|
||||
return pls.ID + "_" + clean + ext
|
||||
}
|
||||
|
||||
func (pls Playlist) CoverArtID() ArtworkID {
|
||||
return artworkIDFromPlaylist(pls)
|
||||
}
|
||||
|
||||
// UploadedImagePath returns the absolute filesystem path for a manually uploaded
|
||||
// playlist cover image. Returns empty string if no image has been uploaded.
|
||||
// This does NOT cover sidecar images or external URLs — those are resolved
|
||||
// by the artwork reader's fallback chain.
|
||||
func (pls Playlist) UploadedImagePath() string {
|
||||
if pls.UploadedImage == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage)
|
||||
}
|
||||
|
||||
type Playlists []Playlist
|
||||
|
||||
type PlaylistRepository interface {
|
||||
|
||||
@@ -7,6 +7,28 @@ import (
|
||||
)
|
||||
|
||||
var _ = Describe("Playlist", func() {
|
||||
Describe("ImageFilename", func() {
|
||||
It("returns ID_cleanname.ext for a normal name", func() {
|
||||
pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"}
|
||||
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg"))
|
||||
})
|
||||
|
||||
It("falls back to ID.ext when name cleans to empty", func() {
|
||||
pls := model.Playlist{ID: "abc123", Name: "!!!"}
|
||||
Expect(pls.ImageFilename(".png")).To(Equal("abc123.png"))
|
||||
})
|
||||
|
||||
It("falls back to ID.ext for empty name", func() {
|
||||
pls := model.Playlist{ID: "abc123", Name: ""}
|
||||
Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg"))
|
||||
})
|
||||
|
||||
It("handles names with special characters", func() {
|
||||
pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"}
|
||||
Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ToM3U8()", func() {
|
||||
var pls model.Playlist
|
||||
BeforeEach(func() {
|
||||
|
||||
@@ -3,25 +3,27 @@ package model
|
||||
import "time"
|
||||
|
||||
type Plugin struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Manifest string `structs:"manifest" json:"manifest"`
|
||||
Config string `structs:"config" json:"config,omitempty"`
|
||||
Users string `structs:"users" json:"users,omitempty"`
|
||||
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||
Enabled bool `structs:"enabled" json:"enabled"`
|
||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||
SHA256 string `structs:"sha256" json:"sha256"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Manifest string `structs:"manifest" json:"manifest"`
|
||||
Config string `structs:"config" json:"config,omitempty"`
|
||||
Users string `structs:"users" json:"users,omitempty"`
|
||||
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||
AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"`
|
||||
Enabled bool `structs:"enabled" json:"enabled"`
|
||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||
SHA256 string `structs:"sha256" json:"sha256"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Plugins []Plugin
|
||||
|
||||
type PluginRepository interface {
|
||||
ResourceRepository
|
||||
ClearErrors() error
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*Plugin, error)
|
||||
|
||||
@@ -134,6 +134,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"has_rating": annotationBoolFilter("rating"),
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
|
||||
@@ -98,6 +98,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"has_rating": annotationBoolFilter("rating"),
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
|
||||
@@ -31,6 +31,14 @@ func (r *pluginRepository) isPermitted() bool {
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *pluginRepository) ClearErrors() error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
_, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
if !r.isPermitted() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
@@ -79,8 +87,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
|
||||
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||
_, err := r.db.NewQuery(`
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
manifest = excluded.manifest,
|
||||
@@ -89,24 +97,26 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
all_users = excluded.all_users,
|
||||
libraries = excluded.libraries,
|
||||
all_libraries = excluded.all_libraries,
|
||||
allow_write_access = excluded.allow_write_access,
|
||||
enabled = excluded.enabled,
|
||||
last_error = excluded.last_error,
|
||||
sha256 = excluded.sha256,
|
||||
updated_at = excluded.updated_at
|
||||
`).Bind(dbx.Params{
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"allow_write_access": plugin.AllowWriteAccess,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -175,6 +175,30 @@ var _ = Describe("PluginRepository", func() {
|
||||
Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ClearErrors", func() {
|
||||
It("clears last_error on all plugins with errors", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"})
|
||||
_ = repo.Put(&model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"})
|
||||
_ = repo.Put(&model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"})
|
||||
|
||||
err := repo.ClearErrors()
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
for _, p := range all {
|
||||
Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
It("succeeds when no plugins have errors", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"})
|
||||
|
||||
err := repo.ClearErrors()
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
|
||||
@@ -38,7 +38,7 @@ type OnBinaryMessageRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Data is the binary data received from the WebSocket, encoded as base64.
|
||||
Data string `json:"data"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// OnErrorRequest is the request provided when an error occurs on a WebSocket connection.
|
||||
|
||||
@@ -30,6 +30,7 @@ components:
|
||||
description: ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
data:
|
||||
type: string
|
||||
format: byte
|
||||
description: Data is the binary data received from the WebSocket, encoded as base64.
|
||||
required:
|
||||
- connectionId
|
||||
|
||||
@@ -282,9 +282,6 @@ type ServiceB interface {
|
||||
|
||||
Entry("option pattern (value, exists bool)",
|
||||
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
|
||||
|
||||
Entry("raw=true binary response",
|
||||
"raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"),
|
||||
)
|
||||
|
||||
It("generates compilable client code for comprehensive service", func() {
|
||||
|
||||
@@ -256,6 +256,15 @@ func GenerateClientRust(svc Service) ([]byte, error) {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
|
||||
}
|
||||
tmpl, err = tmpl.Parse(string(partialContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
|
||||
}
|
||||
|
||||
data := templateData{
|
||||
Service: svc,
|
||||
}
|
||||
@@ -622,6 +631,15 @@ func GenerateCapabilityRust(cap Capability) ([]byte, error) {
|
||||
return nil, fmt.Errorf("parsing template: %w", err)
|
||||
}
|
||||
|
||||
partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading base64_bytes partial: %w", err)
|
||||
}
|
||||
tmpl, err = tmpl.Parse(string(partialContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing base64_bytes partial: %w", err)
|
||||
}
|
||||
|
||||
data := capabilityTemplateData{
|
||||
Package: cap.Name,
|
||||
Capability: cap,
|
||||
|
||||
@@ -264,96 +264,6 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||
})
|
||||
|
||||
It("should generate binary framing for raw=true methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateHost(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should include encoding/binary import for raw methods
|
||||
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||
|
||||
// Should NOT generate a response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
|
||||
|
||||
// Should build binary frame [0x00][4-byte CT len][CT][data]
|
||||
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
|
||||
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
|
||||
|
||||
// Should have writeRawError helper
|
||||
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
|
||||
|
||||
// Should use writeRawError instead of writeError for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
|
||||
})
|
||||
|
||||
It("should generate both writeError and writeRawError for mixed services", func() {
|
||||
svc := Service{
|
||||
Name: "API",
|
||||
Permission: "api",
|
||||
Interface: "APIService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
{
|
||||
Name: "CallRaw",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateHost(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should have both helpers
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteError"))
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
|
||||
|
||||
// Should generate response type for non-raw method only
|
||||
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
|
||||
})
|
||||
|
||||
It("should always include json import for JSON protocol", func() {
|
||||
// All services use JSON protocol, so json import is always needed
|
||||
svc := Service{
|
||||
@@ -717,49 +627,7 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||
})
|
||||
|
||||
It("should generate binary frame parsing for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should import Tuple and struct for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
|
||||
Expect(codeStr).To(ContainSubstring("import struct"))
|
||||
|
||||
// Should return Tuple[str, bytes]
|
||||
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
|
||||
|
||||
// Should parse binary frame instead of JSON
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("struct.unpack"))
|
||||
Expect(codeStr).To(ContainSubstring("return content_type, data"))
|
||||
|
||||
// Should NOT use json.loads for response
|
||||
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
|
||||
})
|
||||
|
||||
It("should not import Tuple or struct for non-raw services", func() {
|
||||
It("should not import base64 for non-byte services", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
@@ -779,8 +647,37 @@ var _ = Describe("Generator", func() {
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("import base64"))
|
||||
})
|
||||
|
||||
It("should generate base64 encoding/decoding for byte fields", func() {
|
||||
svc := Service{
|
||||
Name: "Codec",
|
||||
Permission: "codec",
|
||||
Interface: "CodecService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Encode",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("data", "[]byte")},
|
||||
Returns: []Param{NewParam("result", "[]byte")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should import base64
|
||||
Expect(codeStr).To(ContainSubstring("import base64"))
|
||||
|
||||
// Should base64-encode byte params in request
|
||||
Expect(codeStr).To(ContainSubstring(`base64.b64encode(data).decode("ascii")`))
|
||||
|
||||
// Should base64-decode byte returns in response
|
||||
Expect(codeStr).To(ContainSubstring(`base64.b64decode(response.get("result", ""))`))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -939,46 +836,6 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
|
||||
})
|
||||
|
||||
It("should include encoding/binary import for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientGo(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should include encoding/binary for raw binary frame parsing
|
||||
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||
|
||||
// Should NOT generate response type struct for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
|
||||
|
||||
// Should still generate request type
|
||||
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
|
||||
|
||||
// Should parse binary frame
|
||||
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
|
||||
|
||||
// Should return (string, []byte, error)
|
||||
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateClientGoStub", func() {
|
||||
@@ -1748,22 +1605,17 @@ var _ = Describe("Rust Generation", func() {
|
||||
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
||||
})
|
||||
|
||||
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
||||
It("should generate base64 serde for Vec<u8> fields", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Name: "Codec",
|
||||
Permission: "codec",
|
||||
Interface: "CodecService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
Name: "Encode",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
Params: []Param{NewParam("data", "[]byte")},
|
||||
Returns: []Param{NewParam("result", "[]byte")},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1773,24 +1625,36 @@ var _ = Describe("Rust Generation", func() {
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
||||
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
||||
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
||||
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
||||
// Should generate base64_bytes serde module
|
||||
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
|
||||
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
|
||||
|
||||
// Should NOT generate response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
||||
// Should add serde(with = "base64_bytes") on Vec<u8> fields
|
||||
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
|
||||
})
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
||||
It("should not generate base64 module when no byte fields", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
Interface: "TestService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should return Result<(String, Vec<u8>), Error>
|
||||
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
||||
code, err := GenerateClientRust(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Should parse binary frame
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
|
||||
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
|
||||
codeStr := string(code)
|
||||
|
||||
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("use base64"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -761,7 +761,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
m := Method{
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Raw: annotation["raw"] == "true",
|
||||
Doc: doc,
|
||||
}
|
||||
|
||||
@@ -800,13 +799,6 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
}
|
||||
}
|
||||
|
||||
// Validate raw=true methods: must return exactly (string, []byte, error)
|
||||
if m.Raw {
|
||||
if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" {
|
||||
return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -122,119 +122,6 @@ type TestService interface {
|
||||
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
||||
})
|
||||
|
||||
It("should parse raw=true annotation", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Stream permission=stream
|
||||
type StreamService interface {
|
||||
//nd:hostfunc raw=true
|
||||
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
|
||||
m := services[0].Methods[0]
|
||||
Expect(m.Name).To(Equal("GetStream"))
|
||||
Expect(m.Raw).To(BeTrue())
|
||||
Expect(m.HasError).To(BeTrue())
|
||||
Expect(m.Returns).To(HaveLen(2))
|
||||
Expect(m.Returns[0].Name).To(Equal("contentType"))
|
||||
Expect(m.Returns[0].Type).To(Equal("string"))
|
||||
Expect(m.Returns[1].Name).To(Equal("data"))
|
||||
Expect(m.Returns[1].Type).To(Equal("[]byte"))
|
||||
})
|
||||
|
||||
It("should set Raw=false when raw annotation is absent", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should reject raw=true with invalid return signature", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc raw=true
|
||||
BadRaw(ctx context.Context, uri string) (result string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ParseDirectory(tmpDir)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||
Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)"))
|
||||
})
|
||||
|
||||
It("should reject raw=true without error return", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc raw=true
|
||||
BadRaw(ctx context.Context, uri string) (contentType string, data []byte)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ParseDirectory(tmpDir)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||
})
|
||||
|
||||
It("should parse mixed raw and non-raw methods", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=API permission=api
|
||||
type APIService interface {
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||
|
||||
//nd:hostfunc raw=true
|
||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Methods).To(HaveLen(2))
|
||||
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||
Expect(services[0].Methods[1].Raw).To(BeTrue())
|
||||
Expect(services[0].HasRawMethods()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should handle custom export name", func() {
|
||||
src := `package host
|
||||
|
||||
|
||||
25
plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl
Normal file
25
plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl
Normal file
@@ -0,0 +1,25 @@
|
||||
{{define "base64_bytes_module"}}
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
mod base64_bytes {
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&BASE64.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
{{- end}}
|
||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
{{- if hasHashMap .Capability}}
|
||||
use std::collections::HashMap;
|
||||
{{- end}}
|
||||
{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
||||
|
||||
// Helper functions for skip_serializing_if with numeric types
|
||||
#[allow(dead_code)]
|
||||
@@ -70,6 +71,9 @@ pub struct {{.Name}} {
|
||||
#[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")]
|
||||
{{- else}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
{{- if .IsByteSlice}}
|
||||
#[serde(with = "base64_bytes")]
|
||||
{{- end}}
|
||||
pub {{rustFieldName .Name}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
{{- if .Service.HasErrors}}
|
||||
"errors"
|
||||
@@ -52,7 +49,7 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if and (not .IsErrorOnly) (not .Raw)}}
|
||||
{{- if not .IsErrorOnly}}
|
||||
|
||||
type {{responseType .}} struct {
|
||||
{{- range .Returns}}
|
||||
@@ -98,27 +95,7 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
{{- if .Raw}}
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
{{- else if .IsErrorOnly}}
|
||||
{{- if .IsErrorOnly}}
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
{{- if .Service.HasRawMethods}}
|
||||
import struct
|
||||
{{- if .Service.HasByteFields}}
|
||||
import base64
|
||||
{{- end}}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
|
||||
{{- end}}
|
||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if and .NeedsResultClass (not .Raw)}}
|
||||
{{- if .NeedsResultClass}}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -47,7 +47,7 @@ class {{pythonResultType .}}:
|
||||
{{range .Service.Methods}}
|
||||
|
||||
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
@@ -56,11 +56,7 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
{{.PythonName}}: {{.PythonType}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .Raw}}
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
{{- else if .HasReturns}}
|
||||
{{- if .HasReturns}}
|
||||
|
||||
Returns:
|
||||
{{- if .NeedsResultClass}}
|
||||
@@ -76,7 +72,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
{{- if .HasParams}}
|
||||
request = {
|
||||
{{- range .Params}}
|
||||
{{- if .IsByteSlice}}
|
||||
"{{.JSONName}}": base64.b64encode({{.PythonName}}).decode("ascii"),
|
||||
{{- else}}
|
||||
"{{.JSONName}}": {{.PythonName}},
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
@@ -86,24 +86,6 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _{{exportName .}}(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
{{- if .Raw}}
|
||||
response_bytes = response_mem.bytes()
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
{{- else}}
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
{{if .HasError}}
|
||||
if response.get("error"):
|
||||
@@ -112,10 +94,17 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
{{- if .NeedsResultClass}}
|
||||
return {{pythonResultType .}}(
|
||||
{{- range .Returns}}
|
||||
{{- if .IsByteSlice}}
|
||||
{{.PythonName}}=base64.b64decode(response.get("{{.JSONName}}", "")),
|
||||
{{- else}}
|
||||
{{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
)
|
||||
{{- else if .HasReturns}}
|
||||
{{- if (index .Returns 0).IsByteSlice}}
|
||||
return base64.b64decode(response.get("{{(index .Returns 0).JSONName}}", ""))
|
||||
{{- else}}
|
||||
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}}
|
||||
{{- /* Generate struct definitions */ -}}
|
||||
{{- range .Service.Structs}}
|
||||
{{if .Doc}}
|
||||
@@ -16,6 +17,9 @@ pub struct {{.Name}} {
|
||||
{{- range .Fields}}
|
||||
{{- if .NeedsDefault}}
|
||||
#[serde(default)]
|
||||
{{- end}}
|
||||
{{- if .IsByteSlice}}
|
||||
#[serde(with = "base64_bytes")]
|
||||
{{- end}}
|
||||
pub {{.RustName}}: {{fieldRustType .}},
|
||||
{{- end}}
|
||||
@@ -29,17 +33,22 @@ pub struct {{.Name}} {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct {{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{- if .IsByteSlice}}
|
||||
#[serde(with = "base64_bytes")]
|
||||
{{- end}}
|
||||
{{.RustName}}: {{rustType .}},
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct {{responseType .}} {
|
||||
{{- range .Returns}}
|
||||
#[serde(default)]
|
||||
{{- if .IsByteSlice}}
|
||||
#[serde(with = "base64_bytes")]
|
||||
{{- end}}
|
||||
{{.RustName}}: {{rustType .}},
|
||||
{{- end}}
|
||||
{{- if .HasError}}
|
||||
@@ -48,92 +57,16 @@ struct {{responseType .}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
{{- range .Service.Methods}}
|
||||
{{- if not .Raw}}
|
||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- /* Declare raw extern "C" imports for raw methods */ -}}
|
||||
{{- range .Service.Methods}}
|
||||
{{- if .Raw}}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn {{exportName .}}(offset: u64) -> u64;
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate wrapper functions */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .Raw}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
///
|
||||
/// # Arguments
|
||||
{{- range .Params}}
|
||||
/// * `{{.RustName}}` - {{rustType .}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec<u8>), Error> {
|
||||
{{- if .HasParams}}
|
||||
let req = {{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
|
||||
{{- end}}
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
{{- else}}
|
||||
let input_bytes = b"{}".to_vec();
|
||||
{{- end}}
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { {{exportName .}}(input_mem.offset()) };
|
||||
|
||||
let response_mem = Memory::find(response_offset)
|
||||
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||
let response_bytes = response_mem.to_vec();
|
||||
|
||||
if response_bytes.is_empty() {
|
||||
return Err(Error::msg("empty response from host"));
|
||||
}
|
||||
if response_bytes[0] == 0x01 {
|
||||
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||
return Err(Error::msg(msg));
|
||||
}
|
||||
if response_bytes[0] != 0x00 {
|
||||
return Err(Error::msg("unknown response status"));
|
||||
}
|
||||
if response_bytes.len() < 5 {
|
||||
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||
}
|
||||
let ct_len = u32::from_be_bytes([
|
||||
response_bytes[1],
|
||||
response_bytes[2],
|
||||
response_bytes[3],
|
||||
response_bytes[4],
|
||||
]) as usize;
|
||||
if ct_len > response_bytes.len() - 5 {
|
||||
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||
}
|
||||
let ct_end = 5 + ct_len;
|
||||
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||
let data = response_bytes[ct_end..].to_vec();
|
||||
Ok((content_type, data))
|
||||
}
|
||||
{{- else}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
@@ -209,4 +142,3 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -4,9 +4,6 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -23,7 +20,6 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{responseType .}} struct {
|
||||
@@ -34,7 +30,6 @@ type {{responseType .}} struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||
@@ -56,48 +51,18 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
{{- if .Raw}}
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||
{{- else}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
var req {{requestType .}}
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
{{- if .Raw}}
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||
{{- else}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
// Call the service method
|
||||
{{- if .Raw}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write binary-framed response to plugin memory:
|
||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||
ctBytes := []byte({{lower (index .Returns 0).Name}})
|
||||
frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}}))
|
||||
frame[0] = 0x00 // success
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||
copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}})
|
||||
|
||||
respPtr, err := p.WriteBytes(frame)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
{{- else if .HasReturns}}
|
||||
{{- if .HasReturns}}
|
||||
{{- if .HasError}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
@@ -162,16 +127,3 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- if .Service.HasRawMethods}}
|
||||
|
||||
// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory.
|
||||
// Format: [0x01][UTF-8 error message]
|
||||
func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errMsg := []byte(err.Error())
|
||||
frame := make([]byte, 1+len(errMsg))
|
||||
frame[0] = 0x01 // error
|
||||
copy(frame[1:], errMsg)
|
||||
respPtr, _ := p.WriteBytes(frame)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -173,16 +173,6 @@ func (s Service) HasErrors() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasRawMethods returns true if any method in the service uses raw binary framing.
|
||||
func (s Service) HasRawMethods() bool {
|
||||
for _, m := range s.Methods {
|
||||
if m.Raw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Method represents a host function method within a service.
|
||||
type Method struct {
|
||||
Name string // Go method name (e.g., "Call")
|
||||
@@ -191,7 +181,6 @@ type Method struct {
|
||||
Returns []Param // Return values (excluding error)
|
||||
HasError bool // Whether the method returns an error
|
||||
Doc string // Documentation comment for the method
|
||||
Raw bool // If true, response uses binary framing instead of JSON
|
||||
}
|
||||
|
||||
// FunctionName returns the Extism host function export name.
|
||||
@@ -343,6 +332,52 @@ type Param struct {
|
||||
JSONName string // JSON field name (camelCase)
|
||||
}
|
||||
|
||||
// IsByteSlice returns true if the parameter type is []byte.
|
||||
func (p Param) IsByteSlice() bool {
|
||||
return p.Type == "[]byte"
|
||||
}
|
||||
|
||||
// IsByteSlice returns true if the field type is []byte.
|
||||
func (f FieldDef) IsByteSlice() bool {
|
||||
return f.Type == "[]byte"
|
||||
}
|
||||
|
||||
// HasByteFields returns true if any method params, returns, or struct fields use []byte.
|
||||
func (s Service) HasByteFields() bool {
|
||||
for _, m := range s.Methods {
|
||||
for _, p := range m.Params {
|
||||
if p.IsByteSlice() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, r := range m.Returns {
|
||||
if r.IsByteSlice() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, st := range s.Structs {
|
||||
for _, f := range st.Fields {
|
||||
if f.IsByteSlice() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasByteFields returns true if any capability struct fields use []byte.
|
||||
func (c Capability) HasByteFields() bool {
|
||||
for _, st := range c.Structs {
|
||||
for _, f := range st.Fields {
|
||||
if f.IsByteSlice() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NewParam creates a Param with auto-generated JSON name.
|
||||
func NewParam(name, typ string) Param {
|
||||
return Param{
|
||||
|
||||
@@ -246,6 +246,12 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle primitive types (including []byte which maps to string/byte, not array)
|
||||
if isPrimitiveGoType(goType) {
|
||||
prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType)
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle slice types
|
||||
if strings.HasPrefix(goType, "[]") {
|
||||
elemType := goType[2:]
|
||||
@@ -259,7 +265,7 @@ func buildProperty(field FieldDef, knownTypes map[string]bool) xtpProperty {
|
||||
return prop
|
||||
}
|
||||
|
||||
// Handle primitive types
|
||||
// Handle remaining types
|
||||
prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType)
|
||||
return prop
|
||||
}
|
||||
|
||||
@@ -303,6 +303,45 @@ var _ = Describe("XTP Schema Generation", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("capability with []byte field", func() {
|
||||
It("should map []byte to string with byte format, not array", func() {
|
||||
capability := Capability{
|
||||
Name: "byte_test",
|
||||
SourceFile: "byte_test",
|
||||
Methods: []Export{
|
||||
{ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")},
|
||||
},
|
||||
Structs: []StructDef{
|
||||
{
|
||||
Name: "Input",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Data", Type: "[]byte", JSONTag: "data"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Output",
|
||||
Fields: []FieldDef{
|
||||
{Name: "Value", Type: "string", JSONTag: "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
schema, err := GenerateSchema(capability)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(ValidateXTPSchema(schema)).To(Succeed())
|
||||
|
||||
doc := parseSchema(schema)
|
||||
components := doc["components"].(map[string]any)
|
||||
schemas := components["schemas"].(map[string]any)
|
||||
input := schemas["Input"].(map[string]any)
|
||||
props := input["properties"].(map[string]any)
|
||||
data := props["data"].(map[string]any)
|
||||
Expect(data["type"]).To(Equal("string"))
|
||||
Expect(data["format"]).To(Equal("byte"))
|
||||
Expect(data).NotTo(HaveKey("items"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("capability with nullable ref", func() {
|
||||
It("should mark pointer to enum as nullable with $ref", func() {
|
||||
capability := Capability{
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -38,7 +39,7 @@ def codec_encode(data: bytes) -> bytes:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@@ -49,4 +50,4 @@ def codec_encode(data: bytes) -> bytes:
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", b"")
|
||||
return base64.b64decode(response.get("result", ""))
|
||||
|
||||
@@ -5,10 +5,34 @@
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
mod base64_bytes {
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&BASE64.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodecEncodeRequest {
|
||||
#[serde(with = "base64_bytes")]
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -16,6 +40,7 @@ struct CodecEncodeRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodecEncodeResponse {
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -327,7 +328,7 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"data": data,
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@@ -338,4 +339,4 @@ def comprehensive_byte_slice(data: bytes) -> bytes:
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", b"")
|
||||
return base64.b64decode(response.get("result", ""))
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
mod base64_bytes {
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&BASE64.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -144,6 +167,7 @@ struct ComprehensiveMultipleReturnsResponse {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveByteSliceRequest {
|
||||
#[serde(with = "base64_bytes")]
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -151,6 +175,7 @@ struct ComprehensiveByteSliceRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ComprehensiveByteSliceResponse {
|
||||
#[serde(default)]
|
||||
#[serde(with = "base64_bytes")]
|
||||
result: Vec<u8>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Stream host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package ndpdk
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// stream_getstream is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user stream_getstream
|
||||
func stream_getstream(uint64) uint64
|
||||
|
||||
type streamGetStreamRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
// StreamGetStream calls the stream_getstream host function.
|
||||
// GetStream returns raw binary stream data with content type.
|
||||
func StreamGetStream(uri string) (string, []byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := streamGetStreamRequest{
|
||||
Uri: uri,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := stream_getstream(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the Stream host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Tuple
|
||||
|
||||
import extism
|
||||
import json
|
||||
import struct
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "stream_getstream")
|
||||
def _stream_getstream(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def stream_get_stream(uri: str) -> Tuple[str, bytes]:
|
||||
"""GetStream returns raw binary stream data with content type.
|
||||
|
||||
Args:
|
||||
uri: str parameter.
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"uri": uri,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _stream_getstream(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response_bytes = response_mem.bytes()
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
@@ -1,73 +0,0 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Stream host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StreamGetStreamRequest {
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn stream_getstream(offset: u64) -> u64;
|
||||
}
|
||||
|
||||
/// GetStream returns raw binary stream data with content type.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uri` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_stream(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||
let req = StreamGetStreamRequest {
|
||||
uri: uri.to_owned(),
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { stream_getstream(input_mem.offset()) };
|
||||
|
||||
let response_mem = Memory::find(response_offset)
|
||||
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||
let response_bytes = response_mem.to_vec();
|
||||
|
||||
if response_bytes.is_empty() {
|
||||
return Err(Error::msg("empty response from host"));
|
||||
}
|
||||
if response_bytes[0] == 0x01 {
|
||||
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||
return Err(Error::msg(msg));
|
||||
}
|
||||
if response_bytes[0] != 0x00 {
|
||||
return Err(Error::msg("unknown response status"));
|
||||
}
|
||||
if response_bytes.len() < 5 {
|
||||
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||
}
|
||||
let ct_len = u32::from_be_bytes([
|
||||
response_bytes[1],
|
||||
response_bytes[2],
|
||||
response_bytes[3],
|
||||
response_bytes[4],
|
||||
]) as usize;
|
||||
if ct_len > response_bytes.len() - 5 {
|
||||
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||
}
|
||||
let ct_end = 5 + ct_len;
|
||||
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||
let data = response_bytes[ct_end..].to_vec();
|
||||
Ok((content_type, data))
|
||||
}
|
||||
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
@@ -1,10 +0,0 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Stream permission=stream
|
||||
type StreamService interface {
|
||||
// GetStream returns raw binary stream data with content type.
|
||||
//nd:hostfunc raw=true
|
||||
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
@@ -23,6 +23,20 @@ type KVStoreService interface {
|
||||
//nd:hostfunc
|
||||
Set(ctx context.Context, key string, value []byte) error
|
||||
|
||||
// SetWithTTL stores a byte value with the given key and a time-to-live.
|
||||
//
|
||||
// After ttlSeconds, the key is treated as non-existent and will be
|
||||
// cleaned up lazily. ttlSeconds must be greater than 0.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key (max 256 bytes, UTF-8)
|
||||
// - value: The byte slice to store
|
||||
// - ttlSeconds: Time-to-live in seconds (must be > 0)
|
||||
//
|
||||
// Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
//nd:hostfunc
|
||||
SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error
|
||||
|
||||
// Get retrieves a byte value from storage.
|
||||
//
|
||||
// Parameters:
|
||||
@@ -32,14 +46,15 @@ type KVStoreService interface {
|
||||
//nd:hostfunc
|
||||
Get(ctx context.Context, key string) (value []byte, exists bool, err error)
|
||||
|
||||
// Delete removes a value from storage.
|
||||
// GetMany retrieves multiple values in a single call.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key
|
||||
// - keys: The storage keys to retrieve
|
||||
//
|
||||
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
// Returns a map of key to value for keys that exist and have not expired.
|
||||
// Missing or expired keys are omitted from the result.
|
||||
//nd:hostfunc
|
||||
Delete(ctx context.Context, key string) error
|
||||
GetMany(ctx context.Context, keys []string) (values map[string][]byte, err error)
|
||||
|
||||
// Has checks if a key exists in storage.
|
||||
//
|
||||
@@ -59,6 +74,24 @@ type KVStoreService interface {
|
||||
//nd:hostfunc
|
||||
List(ctx context.Context, prefix string) (keys []string, err error)
|
||||
|
||||
// Delete removes a value from storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key
|
||||
//
|
||||
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
//nd:hostfunc
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// DeleteByPrefix removes all keys matching the given prefix.
|
||||
//
|
||||
// Parameters:
|
||||
// - prefix: Key prefix to match (must not be empty)
|
||||
//
|
||||
// Returns the number of keys deleted. Includes expired keys.
|
||||
//nd:hostfunc
|
||||
DeleteByPrefix(ctx context.Context, prefix string) (deletedCount int64, err error)
|
||||
|
||||
// GetStorageUsed returns the total storage used by this plugin in bytes.
|
||||
//nd:hostfunc
|
||||
GetStorageUsed(ctx context.Context) (bytes int64, err error)
|
||||
|
||||
@@ -20,6 +20,18 @@ type KVStoreSetResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreSetWithTTLRequest is the request type for KVStore.SetWithTTL.
|
||||
type KVStoreSetWithTTLRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
TtlSeconds int64 `json:"ttlSeconds"`
|
||||
}
|
||||
|
||||
// KVStoreSetWithTTLResponse is the response type for KVStore.SetWithTTL.
|
||||
type KVStoreSetWithTTLResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreGetRequest is the request type for KVStore.Get.
|
||||
type KVStoreGetRequest struct {
|
||||
Key string `json:"key"`
|
||||
@@ -32,14 +44,15 @@ type KVStoreGetResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreDeleteRequest is the request type for KVStore.Delete.
|
||||
type KVStoreDeleteRequest struct {
|
||||
Key string `json:"key"`
|
||||
// KVStoreGetManyRequest is the request type for KVStore.GetMany.
|
||||
type KVStoreGetManyRequest struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
// KVStoreDeleteResponse is the response type for KVStore.Delete.
|
||||
type KVStoreDeleteResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
// KVStoreGetManyResponse is the response type for KVStore.GetMany.
|
||||
type KVStoreGetManyResponse struct {
|
||||
Values map[string][]byte `json:"values,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreHasRequest is the request type for KVStore.Has.
|
||||
@@ -64,6 +77,27 @@ type KVStoreListResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreDeleteRequest is the request type for KVStore.Delete.
|
||||
type KVStoreDeleteRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// KVStoreDeleteResponse is the response type for KVStore.Delete.
|
||||
type KVStoreDeleteResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreDeleteByPrefixRequest is the request type for KVStore.DeleteByPrefix.
|
||||
type KVStoreDeleteByPrefixRequest struct {
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
// KVStoreDeleteByPrefixResponse is the response type for KVStore.DeleteByPrefix.
|
||||
type KVStoreDeleteByPrefixResponse struct {
|
||||
DeletedCount int64 `json:"deletedCount,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed.
|
||||
type KVStoreGetStorageUsedResponse struct {
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
@@ -75,10 +109,13 @@ type KVStoreGetStorageUsedResponse struct {
|
||||
func RegisterKVStoreHostFunctions(service KVStoreService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newKVStoreSetHostFunction(service),
|
||||
newKVStoreSetWithTTLHostFunction(service),
|
||||
newKVStoreGetHostFunction(service),
|
||||
newKVStoreDeleteHostFunction(service),
|
||||
newKVStoreGetManyHostFunction(service),
|
||||
newKVStoreHasHostFunction(service),
|
||||
newKVStoreListHostFunction(service),
|
||||
newKVStoreDeleteHostFunction(service),
|
||||
newKVStoreDeleteByPrefixHostFunction(service),
|
||||
newKVStoreGetStorageUsedHostFunction(service),
|
||||
}
|
||||
}
|
||||
@@ -114,6 +151,37 @@ func newKVStoreSetHostFunction(service KVStoreService) extism.HostFunction {
|
||||
)
|
||||
}
|
||||
|
||||
func newKVStoreSetWithTTLHostFunction(service KVStoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"kvstore_setwithttl",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req KVStoreSetWithTTLRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
if svcErr := service.SetWithTTL(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil {
|
||||
kvstoreWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := KVStoreSetWithTTLResponse{}
|
||||
kvstoreWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"kvstore_get",
|
||||
@@ -149,9 +217,9 @@ func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction {
|
||||
)
|
||||
}
|
||||
|
||||
func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction {
|
||||
func newKVStoreGetManyHostFunction(service KVStoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"kvstore_delete",
|
||||
"kvstore_getmany",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
@@ -159,20 +227,23 @@ func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req KVStoreDeleteRequest
|
||||
var req KVStoreGetManyRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
if svcErr := service.Delete(ctx, req.Key); svcErr != nil {
|
||||
values, svcErr := service.GetMany(ctx, req.Keys)
|
||||
if svcErr != nil {
|
||||
kvstoreWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := KVStoreDeleteResponse{}
|
||||
resp := KVStoreGetManyResponse{
|
||||
Values: values,
|
||||
}
|
||||
kvstoreWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
@@ -248,6 +319,71 @@ func newKVStoreListHostFunction(service KVStoreService) extism.HostFunction {
|
||||
)
|
||||
}
|
||||
|
||||
func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"kvstore_delete",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req KVStoreDeleteRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
if svcErr := service.Delete(ctx, req.Key); svcErr != nil {
|
||||
kvstoreWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := KVStoreDeleteResponse{}
|
||||
kvstoreWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newKVStoreDeleteByPrefixHostFunction(service KVStoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"kvstore_deletebyprefix",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req KVStoreDeleteByPrefixRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
kvstoreWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
deletedcount, svcErr := service.DeleteByPrefix(ctx, req.Prefix)
|
||||
if svcErr != nil {
|
||||
kvstoreWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := KVStoreDeleteByPrefixResponse{
|
||||
DeletedCount: deletedcount,
|
||||
}
|
||||
kvstoreWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newKVStoreGetStorageUsedHostFunction(service KVStoreService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"kvstore_getstorageused",
|
||||
|
||||
@@ -17,8 +17,8 @@ type SubsonicAPIService interface {
|
||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||
|
||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
//nd:hostfunc raw=true
|
||||
// Designed for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
//nd:hostfunc
|
||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -26,6 +25,13 @@ type SubsonicAPICallRawRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
// SubsonicAPICallRawResponse is the response type for SubsonicAPI.CallRaw.
|
||||
type SubsonicAPICallRawResponse struct {
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
||||
@@ -76,37 +82,28 @@ func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFu
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
subsonicapiWriteRawError(p, stack, err)
|
||||
subsonicapiWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req SubsonicAPICallRawRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
subsonicapiWriteRawError(p, stack, err)
|
||||
subsonicapiWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
||||
if svcErr != nil {
|
||||
subsonicapiWriteRawError(p, stack, svcErr)
|
||||
subsonicapiWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write binary-framed response to plugin memory:
|
||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||
ctBytes := []byte(contenttype)
|
||||
frame := make([]byte, 1+4+len(ctBytes)+len(data))
|
||||
frame[0] = 0x00 // success
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||
copy(frame[5+len(ctBytes):], data)
|
||||
|
||||
respPtr, err := p.WriteBytes(frame)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
// Write JSON response to plugin memory
|
||||
resp := SubsonicAPICallRawResponse{
|
||||
ContentType: contenttype,
|
||||
Data: data,
|
||||
}
|
||||
stack[0] = respPtr
|
||||
subsonicapiWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
@@ -137,14 +134,3 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// subsonicapiWriteRawError writes a binary-framed error response to plugin memory.
|
||||
// Format: [0x01][UTF-8 error message]
|
||||
func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errMsg := []byte(err.Error())
|
||||
frame := make([]byte, 1+len(errMsg))
|
||||
frame[0] = 0x01 // error
|
||||
copy(frame[1:], errMsg)
|
||||
respPtr, _ := p.WriteBytes(frame)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ var _ = Describe("ArtworkService", Ordered, func() {
|
||||
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{}}
|
||||
@@ -229,11 +228,10 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID {
|
||||
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")
|
||||
c := auth.ClaimsFromToken(token)
|
||||
|
||||
id, ok := claims["id"].(string)
|
||||
Expect(ok).To(BeTrue(), "Token should contain 'id' claim")
|
||||
id := c.ID
|
||||
Expect(id).ToNot(BeEmpty(), "Token should contain 'id' claim")
|
||||
|
||||
artID, err := model.ParseArtworkID(id)
|
||||
Expect(err).ToNot(HaveOccurred(), "Failed to parse artwork ID from token")
|
||||
|
||||
@@ -345,7 +345,6 @@ var _ = Describe("CacheService Integration", Ordered, func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
|
||||
@@ -59,7 +59,6 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,17 +24,22 @@ const (
|
||||
maxKeyLength = 256 // Max key length in bytes
|
||||
)
|
||||
|
||||
// notExpiredFilter is the SQL condition to exclude expired keys.
|
||||
const notExpiredFilter = "(expires_at IS NULL OR expires_at >= datetime('now'))"
|
||||
|
||||
const cleanupInterval = 1 * time.Hour
|
||||
|
||||
// kvstoreServiceImpl implements the host.KVStoreService interface.
|
||||
// Each plugin gets its own SQLite database for isolation.
|
||||
type kvstoreServiceImpl struct {
|
||||
pluginName string
|
||||
db *sql.DB
|
||||
maxSize int64
|
||||
currentSize atomic.Int64 // cached total size, updated on Set/Delete
|
||||
pluginName string
|
||||
db *sql.DB
|
||||
maxSize int64
|
||||
}
|
||||
|
||||
// newKVStoreService creates a new kvstoreServiceImpl instance with its own SQLite database.
|
||||
func newKVStoreService(pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) {
|
||||
// The provided context controls the lifetime of the background cleanup goroutine.
|
||||
func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) {
|
||||
// Parse max size from permission, default to 1MB
|
||||
maxSize := int64(defaultMaxKVStoreSize)
|
||||
if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" {
|
||||
@@ -59,46 +66,69 @@ func newKVStoreService(pluginName string, perm *KVStorePermission) (*kvstoreServ
|
||||
db.SetMaxOpenConns(3)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
// Create schema
|
||||
// Apply schema migrations
|
||||
if err := createKVStoreSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("creating kvstore schema: %w", err)
|
||||
return nil, fmt.Errorf("migrating kvstore schema: %w", err)
|
||||
}
|
||||
|
||||
// Load current storage size from database
|
||||
var currentSize int64
|
||||
if err := db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM kvstore`).Scan(¤tSize); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("loading storage size: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)), "currentSize", humanize.Bytes(uint64(currentSize)))
|
||||
log.Debug("Initialized plugin kvstore", "plugin", pluginName, "path", dbPath, "maxSize", humanize.Bytes(uint64(maxSize)))
|
||||
|
||||
svc := &kvstoreServiceImpl{
|
||||
pluginName: pluginName,
|
||||
db: db,
|
||||
maxSize: maxSize,
|
||||
}
|
||||
svc.currentSize.Store(currentSize)
|
||||
go svc.cleanupLoop(ctx)
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// createKVStoreSchema applies schema migrations to the kvstore database.
|
||||
// New migrations must be appended at the end of the slice.
|
||||
func createKVStoreSchema(db *sql.DB) error {
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS kvstore (
|
||||
return migrateDB(db, []string{
|
||||
`CREATE TABLE IF NOT EXISTS kvstore (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value BLOB NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
return err
|
||||
)`,
|
||||
`ALTER TABLE kvstore ADD COLUMN expires_at DATETIME DEFAULT NULL`,
|
||||
`CREATE INDEX idx_kvstore_expires_at ON kvstore(expires_at)`,
|
||||
})
|
||||
}
|
||||
|
||||
// Set stores a byte value with the given key.
|
||||
func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error {
|
||||
// Validate key
|
||||
// storageUsed returns the current total storage used by non-expired keys.
|
||||
func (s *kvstoreServiceImpl) storageUsed(ctx context.Context) (int64, error) {
|
||||
var used int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size), 0) FROM kvstore WHERE `+notExpiredFilter).Scan(&used)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("calculating storage used: %w", err)
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// checkStorageLimit verifies that adding delta bytes would not exceed the storage limit.
|
||||
func (s *kvstoreServiceImpl) checkStorageLimit(ctx context.Context, delta int64) error {
|
||||
if delta <= 0 {
|
||||
return nil
|
||||
}
|
||||
used, err := s.storageUsed(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newTotal := used + delta
|
||||
if newTotal > s.maxSize {
|
||||
return fmt.Errorf("storage limit exceeded: would use %s of %s allowed",
|
||||
humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setValue is the shared implementation for Set and SetWithTTL.
|
||||
// A ttlSeconds of 0 means no expiration.
|
||||
func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
|
||||
if len(key) == 0 {
|
||||
return fmt.Errorf("key cannot be empty")
|
||||
}
|
||||
@@ -108,46 +138,59 @@ func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte)
|
||||
|
||||
newValueSize := int64(len(value))
|
||||
|
||||
// Get current size of this key (if it exists) to calculate delta
|
||||
// Get current size of this key (if it exists and not expired) to calculate delta
|
||||
var oldSize int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ?`, key).Scan(&oldSize)
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&oldSize)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("checking existing key: %w", err)
|
||||
}
|
||||
|
||||
// Check size limits using cached total
|
||||
delta := newValueSize - oldSize
|
||||
newTotal := s.currentSize.Load() + delta
|
||||
if newTotal > s.maxSize {
|
||||
return fmt.Errorf("storage limit exceeded: would use %s of %s allowed",
|
||||
humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize)))
|
||||
if err := s.checkStorageLimit(ctx, newValueSize-oldSize); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Compute expires_at: sql.NullString{Valid:false} sends NULL (no expiration),
|
||||
// otherwise we send a concrete timestamp.
|
||||
var expiresAt sql.NullString
|
||||
if ttlSeconds > 0 {
|
||||
expiresAt = sql.NullString{String: fmt.Sprintf("+%d seconds", ttlSeconds), Valid: true}
|
||||
}
|
||||
|
||||
// Upsert the value
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO kvstore (key, value, size, created_at, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
INSERT INTO kvstore (key, value, size, created_at, updated_at, expires_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, datetime('now', ?))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
size = excluded.size,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, key, value, newValueSize)
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
expires_at = excluded.expires_at
|
||||
`, key, value, newValueSize, expiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("storing value: %w", err)
|
||||
}
|
||||
|
||||
// Update cached size
|
||||
s.currentSize.Add(delta)
|
||||
|
||||
log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize)
|
||||
log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize, "ttlSeconds", ttlSeconds)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set stores a byte value with the given key.
|
||||
func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error {
|
||||
return s.setValue(ctx, key, value, 0)
|
||||
}
|
||||
|
||||
// SetWithTTL stores a byte value with the given key and a time-to-live.
|
||||
func (s *kvstoreServiceImpl) SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error {
|
||||
if ttlSeconds <= 0 {
|
||||
return fmt.Errorf("ttlSeconds must be greater than 0")
|
||||
}
|
||||
return s.setValue(ctx, key, value, ttlSeconds)
|
||||
}
|
||||
|
||||
// Get retrieves a byte value from storage.
|
||||
func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) {
|
||||
var value []byte
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ?`, key).Scan(&value)
|
||||
if err == sql.ErrNoRows {
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
@@ -160,25 +203,11 @@ func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool,
|
||||
|
||||
// Delete removes a value from storage.
|
||||
func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error {
|
||||
// Get size of the key being deleted to update cache
|
||||
var oldSize int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT size FROM kvstore WHERE key = ?`, key).Scan(&oldSize)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Key doesn't exist, nothing to delete
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking key size: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key)
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting value: %w", err)
|
||||
}
|
||||
|
||||
// Update cached size
|
||||
s.currentSize.Add(-oldSize)
|
||||
|
||||
log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key)
|
||||
return nil
|
||||
}
|
||||
@@ -186,7 +215,7 @@ func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error {
|
||||
// Has checks if a key exists in storage.
|
||||
func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ?`, key).Scan(&count)
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("checking key: %w", err)
|
||||
}
|
||||
@@ -200,12 +229,12 @@ func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string,
|
||||
var err error
|
||||
|
||||
if prefix == "" {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore ORDER BY key`)
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE `+notExpiredFilter+` ORDER BY key`)
|
||||
} else {
|
||||
// Escape special LIKE characters in prefix
|
||||
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
|
||||
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' ORDER BY key`, escapedPrefix+"%")
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' AND `+notExpiredFilter+` ORDER BY key`, escapedPrefix+"%")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing keys: %w", err)
|
||||
@@ -231,16 +260,113 @@ func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string,
|
||||
|
||||
// GetStorageUsed returns the total storage used by this plugin in bytes.
|
||||
func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) {
|
||||
used := s.currentSize.Load()
|
||||
used, err := s.storageUsed(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used)
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// Close closes the SQLite database connection.
|
||||
// This is called when the plugin is unloaded.
|
||||
// DeleteByPrefix removes all keys matching the given prefix.
|
||||
func (s *kvstoreServiceImpl) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) {
|
||||
if prefix == "" {
|
||||
return 0, fmt.Errorf("prefix cannot be empty")
|
||||
}
|
||||
|
||||
escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%")
|
||||
escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_")
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key LIKE ? ESCAPE '\'`, escapedPrefix+"%")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("deleting keys: %w", err)
|
||||
}
|
||||
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting deleted count: %w", err)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "KVStore.DeleteByPrefix", "plugin", s.pluginName, "prefix", prefix, "deletedCount", count)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetMany retrieves multiple values in a single call, processing keys in batches.
|
||||
func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
|
||||
if len(keys) == 0 {
|
||||
return map[string][]byte{}, nil
|
||||
}
|
||||
|
||||
const batchSize = 200
|
||||
result := make(map[string][]byte)
|
||||
for chunk := range slice.CollectChunks(slices.Values(keys), batchSize) {
|
||||
placeholders := make([]string, len(chunk))
|
||||
args := make([]any, len(chunk))
|
||||
for i, key := range chunk {
|
||||
placeholders[i] = "?"
|
||||
args[i] = key
|
||||
}
|
||||
|
||||
query := `SELECT key, value FROM kvstore WHERE key IN (` + strings.Join(placeholders, ",") + `) AND ` + notExpiredFilter //nolint:gosec // placeholders are always "?"
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying values: %w", err)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var value []byte
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("scanning value: %w", err)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("iterating values: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
log.Trace(ctx, "KVStore.GetMany", "plugin", s.pluginName, "requested", len(keys), "found", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// cleanupLoop periodically removes expired keys from the database.
|
||||
// It stops when the provided context is cancelled.
|
||||
func (s *kvstoreServiceImpl) cleanupLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.cleanupExpired(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpired removes all expired keys from the database to reclaim disk space.
|
||||
func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`)
|
||||
if err != nil {
|
||||
log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err)
|
||||
return
|
||||
}
|
||||
if count, err := result.RowsAffected(); err == nil && count > 0 {
|
||||
log.Debug("KVStore cleanup completed", "plugin", s.pluginName, "deletedKeys", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Close runs a final cleanup and closes the SQLite database connection.
|
||||
// The cleanup goroutine is stopped by the context passed to newKVStoreService.
|
||||
func (s *kvstoreServiceImpl) Close() error {
|
||||
if s.db != nil {
|
||||
log.Debug("Closing plugin kvstore", "plugin", s.pluginName)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.cleanupExpired(ctx)
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -37,7 +38,7 @@ var _ = Describe("KVStoreService", func() {
|
||||
|
||||
// Create service with 1KB limit for testing
|
||||
maxSize := "1KB"
|
||||
service, err = newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize})
|
||||
service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
@@ -253,7 +254,7 @@ var _ = Describe("KVStoreService", func() {
|
||||
Expect(service.Close()).To(Succeed())
|
||||
|
||||
maxSize := "1KB"
|
||||
service2, err := newKVStoreService("test_plugin", &KVStorePermission{MaxSize: &maxSize})
|
||||
service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer service2.Close()
|
||||
|
||||
@@ -302,7 +303,7 @@ var _ = Describe("KVStoreService", func() {
|
||||
|
||||
Describe("Plugin Isolation", func() {
|
||||
It("isolates data between plugins", func() {
|
||||
service2, err := newKVStoreService("other_plugin", &KVStorePermission{})
|
||||
service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer service2.Close()
|
||||
|
||||
@@ -321,7 +322,7 @@ var _ = Describe("KVStoreService", func() {
|
||||
})
|
||||
|
||||
It("creates separate database files per plugin", func() {
|
||||
service2, err := newKVStoreService("other_plugin", &KVStorePermission{})
|
||||
service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer service2.Close()
|
||||
|
||||
@@ -343,6 +344,309 @@ var _ = Describe("KVStoreService", func() {
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TTL Expiration", func() {
|
||||
It("Get returns not-exists for expired keys", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('expired_key', 'old', 3, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
value, exists, err := service.Get(ctx, "expired_key")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeFalse())
|
||||
Expect(value).To(BeNil())
|
||||
})
|
||||
It("Has returns false for expired keys", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('expired_has', 'old', 3, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
exists, err := service.Has(ctx, "expired_has")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeFalse())
|
||||
})
|
||||
It("List excludes expired keys", func() {
|
||||
Expect(service.Set(ctx, "live:1", []byte("alive"))).To(Succeed())
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('live:expired', 'dead', 4, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
keys, err := service.List(ctx, "live:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(keys).To(HaveLen(1))
|
||||
Expect(keys).To(ContainElement("live:1"))
|
||||
})
|
||||
It("Get returns value for non-expired keys with TTL", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('future_key', 'still alive', 11, datetime('now', '+3600 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
value, exists, err := service.Get(ctx, "future_key")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
Expect(value).To(Equal([]byte("still alive")))
|
||||
})
|
||||
It("Set clears expires_at from a key previously set with TTL", func() {
|
||||
// Insert a key with a TTL that has already expired
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('ttl_then_set', 'temp', 4, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Overwrite with Set (no TTL) — should become permanent
|
||||
err = service.Set(ctx, "ttl_then_set", []byte("permanent"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should exist because Set cleared expires_at
|
||||
value, exists, err := service.Get(ctx, "ttl_then_set")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
Expect(value).To(Equal([]byte("permanent")))
|
||||
|
||||
// Verify expires_at is actually NULL
|
||||
var expiresAt *string
|
||||
Expect(service.db.QueryRow(`SELECT expires_at FROM kvstore WHERE key = 'ttl_then_set'`).Scan(&expiresAt)).To(Succeed())
|
||||
Expect(expiresAt).To(BeNil())
|
||||
})
|
||||
It("expired keys are not counted in storage used", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('expired_key', '12345', 5, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Expired keys should not be counted
|
||||
used, err := service.GetStorageUsed(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(used).To(Equal(int64(0)))
|
||||
})
|
||||
It("cleanup removes expired rows from disk", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('cleanup_me', '12345', 5, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Row exists in DB but is logically expired
|
||||
var count int
|
||||
Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed())
|
||||
Expect(count).To(Equal(1))
|
||||
|
||||
service.cleanupExpired(ctx)
|
||||
|
||||
// Row should be physically deleted
|
||||
Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed())
|
||||
Expect(count).To(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SetWithTTL", func() {
|
||||
It("stores value that is retrievable before expiry", func() {
|
||||
err := service.SetWithTTL(ctx, "ttl_key", []byte("ttl_value"), 3600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
value, exists, err := service.Get(ctx, "ttl_key")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
Expect(value).To(Equal([]byte("ttl_value")))
|
||||
})
|
||||
|
||||
It("value is not retrievable after expiry", func() {
|
||||
// Insert a key with an already-expired TTL
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('short_ttl', 'gone_soon', 9, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, exists, err := service.Get(ctx, "short_ttl")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeFalse())
|
||||
})
|
||||
|
||||
It("rejects ttlSeconds <= 0", func() {
|
||||
err := service.SetWithTTL(ctx, "bad_ttl", []byte("value"), 0)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0"))
|
||||
|
||||
err = service.SetWithTTL(ctx, "bad_ttl", []byte("value"), -5)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0"))
|
||||
})
|
||||
|
||||
It("validates key same as Set", func() {
|
||||
err := service.SetWithTTL(ctx, "", []byte("value"), 60)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("key cannot be empty"))
|
||||
})
|
||||
|
||||
It("enforces size limits same as Set", func() {
|
||||
bigValue := make([]byte, 2048)
|
||||
err := service.SetWithTTL(ctx, "big_ttl", bigValue, 60)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("storage limit exceeded"))
|
||||
})
|
||||
|
||||
It("overwrites existing key and updates TTL", func() {
|
||||
// Insert a key with an already-expired TTL
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('overwrite_ttl', 'first', 5, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Overwrite with a long TTL — should be retrievable
|
||||
err = service.SetWithTTL(ctx, "overwrite_ttl", []byte("second"), 3600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
value, exists, err := service.Get(ctx, "overwrite_ttl")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exists).To(BeTrue())
|
||||
Expect(value).To(Equal([]byte("second")))
|
||||
})
|
||||
|
||||
It("tracks storage correctly", func() {
|
||||
err := service.SetWithTTL(ctx, "sized_ttl", []byte("12345"), 3600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
used, err := service.GetStorageUsed(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(used).To(Equal(int64(5)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteByPrefix", func() {
|
||||
BeforeEach(func() {
|
||||
Expect(service.Set(ctx, "cache:user:1", []byte("Alice"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "cache:user:2", []byte("Bob"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "cache:item:1", []byte("Widget"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "data:important", []byte("keep"))).To(Succeed())
|
||||
})
|
||||
|
||||
It("deletes all keys with the given prefix", func() {
|
||||
deleted, err := service.DeleteByPrefix(ctx, "cache:user:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(deleted).To(Equal(int64(2)))
|
||||
|
||||
keys, err := service.List(ctx, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(keys).To(HaveLen(2))
|
||||
Expect(keys).To(ContainElements("cache:item:1", "data:important"))
|
||||
})
|
||||
|
||||
It("rejects empty prefix", func() {
|
||||
_, err := service.DeleteByPrefix(ctx, "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("prefix cannot be empty"))
|
||||
})
|
||||
|
||||
It("returns 0 when no keys match", func() {
|
||||
deleted, err := service.DeleteByPrefix(ctx, "nonexistent:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(deleted).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("updates storage size correctly", func() {
|
||||
usedBefore, _ := service.GetStorageUsed(ctx)
|
||||
Expect(usedBefore).To(BeNumerically(">", 0))
|
||||
|
||||
_, err := service.DeleteByPrefix(ctx, "cache:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
usedAfter, _ := service.GetStorageUsed(ctx)
|
||||
Expect(usedAfter).To(Equal(int64(4)))
|
||||
})
|
||||
|
||||
It("handles special LIKE characters in prefix", func() {
|
||||
Expect(service.Set(ctx, "test%special", []byte("v1"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "test_special", []byte("v2"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "testXspecial", []byte("v3"))).To(Succeed())
|
||||
|
||||
deleted, err := service.DeleteByPrefix(ctx, "test%")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(deleted).To(Equal(int64(1)))
|
||||
|
||||
exists, _ := service.Has(ctx, "test_special")
|
||||
Expect(exists).To(BeTrue())
|
||||
exists, _ = service.Has(ctx, "testXspecial")
|
||||
Expect(exists).To(BeTrue())
|
||||
})
|
||||
|
||||
It("also deletes expired keys matching prefix", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('cache:expired', 'old', 3, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
deleted, err := service.DeleteByPrefix(ctx, "cache:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(deleted).To(Equal(int64(4)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetMany", func() {
|
||||
BeforeEach(func() {
|
||||
Expect(service.Set(ctx, "key1", []byte("value1"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "key2", []byte("value2"))).To(Succeed())
|
||||
Expect(service.Set(ctx, "key3", []byte("value3"))).To(Succeed())
|
||||
})
|
||||
|
||||
It("retrieves multiple values at once", func() {
|
||||
values, err := service.GetMany(ctx, []string{"key1", "key2", "key3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(values).To(HaveLen(3))
|
||||
Expect(values["key1"]).To(Equal([]byte("value1")))
|
||||
Expect(values["key2"]).To(Equal([]byte("value2")))
|
||||
Expect(values["key3"]).To(Equal([]byte("value3")))
|
||||
})
|
||||
|
||||
It("omits missing keys from result", func() {
|
||||
values, err := service.GetMany(ctx, []string{"key1", "missing", "key3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(values).To(HaveLen(2))
|
||||
Expect(values["key1"]).To(Equal([]byte("value1")))
|
||||
Expect(values["key3"]).To(Equal([]byte("value3")))
|
||||
_, hasMissing := values["missing"]
|
||||
Expect(hasMissing).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns empty map for empty keys slice", func() {
|
||||
values, err := service.GetMany(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty map for nil keys slice", func() {
|
||||
values, err := service.GetMany(ctx, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("excludes expired keys", func() {
|
||||
_, err := service.db.Exec(`
|
||||
INSERT INTO kvstore (key, value, size, expires_at)
|
||||
VALUES ('expired_many', 'old', 3, datetime('now', '-1 seconds'))
|
||||
`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
values, err := service.GetMany(ctx, []string{"key1", "expired_many"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(values).To(HaveLen(1))
|
||||
Expect(values["key1"]).To(Equal([]byte("value1")))
|
||||
})
|
||||
|
||||
It("handles all keys missing", func() {
|
||||
values, err := service.GetMany(ctx, []string{"nope1", "nope2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(values).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("KVStoreService Integration", Ordered, func() {
|
||||
@@ -373,7 +677,6 @@ var _ = Describe("KVStoreService Integration", Ordered, func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
conf.Server.DataFolder = tmpDir
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin
|
||||
@@ -416,17 +719,21 @@ var _ = Describe("KVStoreService Integration", Ordered, func() {
|
||||
|
||||
Describe("KVStore Operations via Plugin", func() {
|
||||
type testKVStoreInput struct {
|
||||
Operation string `json:"operation"`
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Operation string `json:"operation"`
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
TTLSeconds int64 `json:"ttl_seconds,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
}
|
||||
type testKVStoreOutput struct {
|
||||
Value []byte `json:"value,omitempty"`
|
||||
Exists bool `json:"exists,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
StorageUsed int64 `json:"storage_used,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
Value []byte `json:"value,omitempty"`
|
||||
Values map[string][]byte `json:"values,omitempty"`
|
||||
Exists bool `json:"exists,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
StorageUsed int64 `json:"storage_used,omitempty"`
|
||||
DeletedCount int64 `json:"deleted_count,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
callTestKVStore := func(ctx context.Context, input testKVStoreInput) (*testKVStoreOutput, error) {
|
||||
@@ -594,6 +901,106 @@ var _ = Describe("KVStoreService Integration", Ordered, func() {
|
||||
Expect(output.Exists).To(BeTrue())
|
||||
Expect(output.Value).To(Equal(binaryData))
|
||||
})
|
||||
|
||||
It("should set value with TTL and expire it", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Set value with 1 second TTL
|
||||
_, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "set_with_ttl",
|
||||
Key: "ttl_key",
|
||||
Value: []byte("temporary"),
|
||||
TTLSeconds: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Immediately should exist
|
||||
output, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "get",
|
||||
Key: "ttl_key",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Exists).To(BeTrue())
|
||||
Expect(output.Value).To(Equal([]byte("temporary")))
|
||||
|
||||
// Poll until the key expires (1s TTL)
|
||||
Eventually(func(g Gomega) {
|
||||
output, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "get",
|
||||
Key: "ttl_key",
|
||||
})
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(output.Exists).To(BeFalse())
|
||||
}).WithTimeout(3 * time.Second).WithPolling(200 * time.Millisecond).Should(Succeed())
|
||||
})
|
||||
|
||||
It("should delete keys by prefix", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Set multiple keys with shared prefix
|
||||
for _, key := range []string{"del_prefix:a", "del_prefix:b", "keep:c"} {
|
||||
_, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "set",
|
||||
Key: key,
|
||||
Value: []byte("value"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
// Delete by prefix
|
||||
output, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "delete_by_prefix",
|
||||
Prefix: "del_prefix:",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.DeletedCount).To(Equal(int64(2)))
|
||||
|
||||
// Verify remaining key
|
||||
getOutput, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "has",
|
||||
Key: "keep:c",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(getOutput.Exists).To(BeTrue())
|
||||
|
||||
// Verify deleted keys are gone
|
||||
getOutput, err = callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "has",
|
||||
Key: "del_prefix:a",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(getOutput.Exists).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should get many values at once", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Set multiple keys
|
||||
for _, kv := range []struct{ k, v string }{
|
||||
{"many:1", "val1"},
|
||||
{"many:2", "val2"},
|
||||
{"many:3", "val3"},
|
||||
} {
|
||||
_, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "set",
|
||||
Key: kv.k,
|
||||
Value: []byte(kv.v),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
// Get many, including a missing key
|
||||
output, err := callTestKVStore(ctx, testKVStoreInput{
|
||||
Operation: "get_many",
|
||||
Keys: []string{"many:1", "many:3", "many:missing"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Values).To(HaveLen(2))
|
||||
Expect(output.Values["many:1"]).To(Equal([]byte("val1")))
|
||||
Expect(output.Values["many:3"]).To(Equal([]byte("val3")))
|
||||
_, hasMissing := output.Values["many:missing"]
|
||||
Expect(hasMissing).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Database Isolation", func() {
|
||||
|
||||
@@ -264,7 +264,6 @@ var _ = Describe("LibraryService", Ordered, func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Create mock &tests.MockLibraryRepo{}
|
||||
mockLibRepo := &tests.MockLibraryRepo{}
|
||||
@@ -360,7 +359,6 @@ var _ = Describe("LibraryService Integration", Ordered, func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin and library
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
|
||||
@@ -188,12 +188,6 @@ func (s *schedulerServiceImpl) invokeCallback(ctx context.Context, scheduleID st
|
||||
return
|
||||
}
|
||||
|
||||
// Check if plugin has the scheduler capability
|
||||
if !hasCapability(instance.capabilities, CapabilityScheduler) {
|
||||
log.Warn(ctx, "Plugin does not have scheduler capability", "plugin", s.pluginName, "scheduleID", scheduleID)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare callback input
|
||||
input := capabilities.SchedulerCallbackRequest{
|
||||
ScheduleID: scheduleID,
|
||||
|
||||
@@ -53,7 +53,6 @@ var _ = Describe("SchedulerService", Ordered, func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Create mock scheduler and timer registry
|
||||
mockSched = newMockScheduler()
|
||||
|
||||
@@ -46,7 +46,6 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock router and data store
|
||||
router = &fakeSubsonicRouter{}
|
||||
|
||||
@@ -486,7 +486,6 @@ func setupTestUsersConfig(tmpDir string) {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
}
|
||||
|
||||
// testUsersInput represents input for test-users plugin calls
|
||||
|
||||
@@ -2,7 +2,6 @@ package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
@@ -355,7 +354,7 @@ func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connecti
|
||||
func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) {
|
||||
invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{
|
||||
ConnectionID: connectionID,
|
||||
Data: base64.StdEncoding.EncodeToString(data),
|
||||
Data: data,
|
||||
}, "binary message", connectionID)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ package plugins
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
|
||||
"encoding/hex"
|
||||
"maps"
|
||||
"net/http"
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -54,7 +53,6 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
@@ -295,10 +293,14 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
Describe("Plugin Callbacks", func() {
|
||||
var wsServer *httptest.Server
|
||||
var serverConn *websocket.Conn
|
||||
var serverMessages []string
|
||||
var serverBinaryMessages [][]byte
|
||||
var serverMu sync.Mutex
|
||||
|
||||
BeforeEach(func() {
|
||||
serverConn = nil
|
||||
serverMessages = nil
|
||||
serverBinaryMessages = nil
|
||||
|
||||
upgrader := websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
@@ -312,12 +314,19 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
serverConn = conn
|
||||
serverMu.Unlock()
|
||||
|
||||
// Keep connection open
|
||||
// Read and store messages
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
msgType, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
serverMu.Lock()
|
||||
if msgType == websocket.BinaryMessage {
|
||||
serverBinaryMessages = append(serverBinaryMessages, msg)
|
||||
} else {
|
||||
serverMessages = append(serverMessages, string(msg))
|
||||
}
|
||||
serverMu.Unlock()
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -336,36 +345,10 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
}
|
||||
})
|
||||
|
||||
It("should invoke OnTextMessage callback when receiving text", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://")
|
||||
connID, err := testService.Connect(ctx, wsURL, nil, "text-cb-conn")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for server to have the connection
|
||||
Eventually(func() *websocket.Conn {
|
||||
serverMu.Lock()
|
||||
defer serverMu.Unlock()
|
||||
return serverConn
|
||||
}).ShouldNot(BeNil())
|
||||
|
||||
// Send message from server to plugin
|
||||
serverMu.Lock()
|
||||
err = serverConn.WriteMessage(websocket.TextMessage, []byte("test message"))
|
||||
serverMu.Unlock()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The plugin should have received the callback
|
||||
// We can verify by checking the plugin's stored messages via vars
|
||||
// For now we just verify no errors occurred
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_ = connID
|
||||
})
|
||||
|
||||
It("should invoke OnBinaryMessage callback when receiving binary", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://")
|
||||
connID, err := testService.Connect(ctx, wsURL, nil, "binary-cb-conn")
|
||||
_, err := testService.Connect(ctx, wsURL, nil, "binary-cb-conn")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for server to have the connection
|
||||
@@ -382,9 +365,12 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
serverMu.Unlock()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Give time for callback to execute
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_ = connID
|
||||
// Plugin echoes binary data back as a binary message
|
||||
Eventually(func() [][]byte {
|
||||
serverMu.Lock()
|
||||
defer serverMu.Unlock()
|
||||
return serverBinaryMessages
|
||||
}).Should(ContainElement(binaryData))
|
||||
})
|
||||
|
||||
It("should invoke OnClose callback when server closes connection", func() {
|
||||
@@ -466,7 +452,7 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
It("should allow plugin to send messages via host function", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://")
|
||||
connID, err := testService.Connect(ctx, wsURL, nil, "host-send-conn")
|
||||
_, err := testService.Connect(ctx, wsURL, nil, "host-send-conn")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Wait for server to have the connection
|
||||
@@ -488,7 +474,6 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
defer serverMu.Unlock()
|
||||
return serverMessages
|
||||
}).Should(ContainElement("echo:echo"))
|
||||
_ = connID
|
||||
})
|
||||
|
||||
It("should allow plugin to close connection via host function", func() {
|
||||
@@ -629,6 +614,3 @@ func findWebSocketService(m *Manager, pluginName string) *webSocketServiceImpl {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure base64 import is used
|
||||
var _ = base64.StdEncoding
|
||||
|
||||
@@ -146,6 +146,12 @@ func (m *Manager) Start(ctx context.Context) error {
|
||||
|
||||
log.Info(ctx, "Starting plugin manager", "folder", folder)
|
||||
|
||||
// Clear previous error states so plugins can be retried on restart
|
||||
adminCtx := adminContext(ctx)
|
||||
if err := m.ds.Plugin(adminCtx).ClearErrors(); err != nil {
|
||||
log.Error(ctx, "Error clearing plugin errors", err)
|
||||
}
|
||||
|
||||
// Sync plugins folder with DB
|
||||
if err := m.syncPlugins(ctx, folder); err != nil {
|
||||
log.Error(ctx, "Error syncing plugins with DB", err)
|
||||
@@ -428,10 +434,11 @@ func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, a
|
||||
// If the plugin is enabled, it will be reloaded with the new settings.
|
||||
// If the plugin requires library permission and no libraries are configured (and allLibraries is false),
|
||||
// the plugin will be automatically disabled.
|
||||
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error {
|
||||
func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error {
|
||||
return m.updatePluginSettings(ctx, id, func(p *model.Plugin) {
|
||||
p.Libraries = librariesJSON
|
||||
p.AllLibraries = allLibraries
|
||||
p.AllowWriteAccess = allowWriteAccess
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -81,48 +81,66 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() {
|
||||
Expect(calls[0].elapsed).To(BeNumerically(">=", 0))
|
||||
})
|
||||
|
||||
It("records metrics for failed plugin calls (error returned)", func() {
|
||||
// Create a manager with error config to force plugin errors
|
||||
errorRecorder := &mockMetricsRecorder{}
|
||||
errorManager, _ := createTestManagerWithPluginsAndMetrics(
|
||||
map[string]map[string]string{
|
||||
"test-metadata-agent": {"error": "simulated error"},
|
||||
},
|
||||
errorRecorder,
|
||||
"test-metadata-agent"+PackageExtension,
|
||||
Context("with error config", Ordered, func() {
|
||||
var (
|
||||
errorRecorder *mockMetricsRecorder
|
||||
errorAgent agents.Interface
|
||||
)
|
||||
|
||||
errorAgent, ok := errorManager.LoadMediaAgent("test-metadata-agent")
|
||||
Expect(ok).To(BeTrue())
|
||||
BeforeAll(func() {
|
||||
errorRecorder = &mockMetricsRecorder{}
|
||||
errorManager, _ := createTestManagerWithPluginsAndMetrics(
|
||||
map[string]map[string]string{
|
||||
"test-metadata-agent": {"error": "simulated error"},
|
||||
},
|
||||
errorRecorder,
|
||||
"test-metadata-agent"+PackageExtension,
|
||||
)
|
||||
|
||||
retriever := errorAgent.(agents.ArtistBiographyRetriever)
|
||||
_, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
var ok bool
|
||||
errorAgent, ok = errorManager.LoadMediaAgent("test-metadata-agent")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
calls := errorRecorder.getCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].plugin).To(Equal("test-metadata-agent"))
|
||||
Expect(calls[0].method).To(Equal(FuncGetArtistBiography))
|
||||
Expect(calls[0].ok).To(BeFalse())
|
||||
It("records metrics for failed plugin calls (error returned)", func() {
|
||||
retriever := errorAgent.(agents.ArtistBiographyRetriever)
|
||||
_, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
|
||||
calls := errorRecorder.getCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].plugin).To(Equal("test-metadata-agent"))
|
||||
Expect(calls[0].method).To(Equal(FuncGetArtistBiography))
|
||||
Expect(calls[0].ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
It("does not record metrics for not-implemented functions", func() {
|
||||
// Use partial metadata agent that doesn't implement GetArtistMBID
|
||||
partialRecorder := &mockMetricsRecorder{}
|
||||
partialManager, _ := createTestManagerWithPluginsAndMetrics(
|
||||
nil,
|
||||
partialRecorder,
|
||||
"partial-metadata-agent"+PackageExtension,
|
||||
Context("with partial metadata agent", Ordered, func() {
|
||||
var (
|
||||
partialRecorder *mockMetricsRecorder
|
||||
partialAgent agents.Interface
|
||||
)
|
||||
|
||||
partialAgent, ok := partialManager.LoadMediaAgent("partial-metadata-agent")
|
||||
Expect(ok).To(BeTrue())
|
||||
BeforeAll(func() {
|
||||
partialRecorder = &mockMetricsRecorder{}
|
||||
partialManager, _ := createTestManagerWithPluginsAndMetrics(
|
||||
nil,
|
||||
partialRecorder,
|
||||
"partial-metadata-agent"+PackageExtension,
|
||||
)
|
||||
|
||||
retriever := partialAgent.(agents.ArtistMBIDRetriever)
|
||||
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
|
||||
Expect(err).To(MatchError(errNotImplemented))
|
||||
var ok bool
|
||||
partialAgent, ok = partialManager.LoadMediaAgent("partial-metadata-agent")
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
|
||||
calls := partialRecorder.getCalls()
|
||||
Expect(calls).To(HaveLen(0))
|
||||
It("does not record metrics for not-implemented functions", func() {
|
||||
retriever := partialAgent.(agents.ArtistMBIDRetriever)
|
||||
_, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist")
|
||||
Expect(err).To(MatchError(errNotImplemented))
|
||||
|
||||
calls := partialRecorder.getCalls()
|
||||
Expect(calls).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,7 +103,7 @@ var hostServices = []hostServiceEntry{
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Kvstore
|
||||
service, err := newKVStoreService(ctx.pluginName, perm)
|
||||
service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm)
|
||||
if err != nil {
|
||||
log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err)
|
||||
return nil, nil
|
||||
@@ -226,6 +226,8 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
|
||||
// loadPluginWithConfig loads a plugin with configuration from DB.
|
||||
// The p.Path should point to an .ndp package file.
|
||||
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
ctx := log.NewContext(m.ctx, "plugin", p.ID)
|
||||
|
||||
if m.stopped.Load() {
|
||||
return fmt.Errorf("manager is stopped")
|
||||
}
|
||||
@@ -283,27 +285,13 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
|
||||
// Configure filesystem access for library permission
|
||||
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
|
||||
adminCtx := adminContext(m.ctx)
|
||||
adminCtx := adminContext(ctx)
|
||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
|
||||
}
|
||||
|
||||
// Build a set of allowed library IDs for fast lookup
|
||||
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraries))
|
||||
for _, id := range allowedLibraries {
|
||||
allowedLibrarySet[id] = struct{}{}
|
||||
}
|
||||
|
||||
allowedPaths := make(map[string]string)
|
||||
for _, lib := range libraries {
|
||||
// Only mount if allLibraries is true or library is in the allowed list
|
||||
if p.AllLibraries {
|
||||
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
|
||||
} else if _, ok := allowedLibrarySet[lib.ID]; ok {
|
||||
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
|
||||
}
|
||||
}
|
||||
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
|
||||
pluginManifest.AllowedPaths = allowedPaths
|
||||
}
|
||||
|
||||
@@ -339,7 +327,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
// Enable experimental threads if requested in manifest
|
||||
if pkg.Manifest.HasExperimentalThreads() {
|
||||
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
|
||||
log.Debug(m.ctx, "Enabling experimental threads support", "plugin", p.ID)
|
||||
log.Debug(ctx, "Enabling experimental threads support")
|
||||
}
|
||||
|
||||
extismConfig := extism.PluginConfig{
|
||||
@@ -347,24 +335,24 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
RuntimeConfig: runtimeConfig,
|
||||
EnableHttpResponseHeaders: true,
|
||||
}
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling plugin: %w", err)
|
||||
}
|
||||
|
||||
// Create instance to detect capabilities
|
||||
instance, err := compiled.Instance(m.ctx, extism.PluginInstanceConfig{})
|
||||
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
|
||||
if err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
compiled.Close(ctx)
|
||||
return fmt.Errorf("creating instance: %w", err)
|
||||
}
|
||||
instance.SetLogger(extismLogger(p.ID))
|
||||
capabilities := detectCapabilities(instance)
|
||||
instance.Close(m.ctx)
|
||||
instance.Close(ctx)
|
||||
|
||||
// Validate manifest against detected capabilities
|
||||
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
|
||||
compiled.Close(m.ctx)
|
||||
compiled.Close(ctx)
|
||||
return fmt.Errorf("manifest validation: %w", err)
|
||||
}
|
||||
|
||||
@@ -383,7 +371,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
m.mu.Unlock()
|
||||
|
||||
// Call plugin init function
|
||||
callPluginInit(m.ctx, m.plugins[p.ID])
|
||||
callPluginInit(ctx, m.plugins[p.ID])
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -414,3 +402,32 @@ func parsePluginConfig(configJSON string) (map[string]string, error) {
|
||||
}
|
||||
return pluginConfig, nil
|
||||
}
|
||||
|
||||
// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access.
|
||||
// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only.
|
||||
// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included.
|
||||
func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string {
|
||||
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
|
||||
for _, id := range allowedLibraryIDs {
|
||||
allowedLibrarySet[id] = struct{}{}
|
||||
}
|
||||
allowedPaths := make(map[string]string)
|
||||
for _, lib := range libraries {
|
||||
_, allowed := allowedLibrarySet[lib.ID]
|
||||
if allLibraries || allowed {
|
||||
mountPoint := toPluginMountPoint(int32(lib.ID))
|
||||
hostPath := lib.Path
|
||||
if !allowWriteAccess {
|
||||
hostPath = "ro:" + hostPath
|
||||
}
|
||||
allowedPaths[hostPath] = mountPoint
|
||||
log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath)
|
||||
}
|
||||
}
|
||||
if allowWriteAccess {
|
||||
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
|
||||
} else {
|
||||
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
|
||||
}
|
||||
return allowedPaths
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@@ -58,3 +59,66 @@ var _ = Describe("parsePluginConfig", func() {
|
||||
Expect(result).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("buildAllowedPaths", func() {
|
||||
var libraries model.Libraries
|
||||
|
||||
BeforeEach(func() {
|
||||
libraries = model.Libraries{
|
||||
{ID: 1, Path: "/music/library1"},
|
||||
{ID: 2, Path: "/music/library2"},
|
||||
{ID: 3, Path: "/music/library3"},
|
||||
}
|
||||
})
|
||||
|
||||
Context("read-only (default)", func() {
|
||||
It("mounts all libraries with ro: prefix when allLibraries is true", func() {
|
||||
result := buildAllowedPaths(nil, libraries, nil, true, false)
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2"))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
|
||||
})
|
||||
|
||||
It("mounts only selected libraries with ro: prefix", func() {
|
||||
result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false)
|
||||
Expect(result).To(HaveLen(2))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1"))
|
||||
Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3"))
|
||||
Expect(result).ToNot(HaveKey("ro:/music/library2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("read-write (allowWriteAccess=true)", func() {
|
||||
It("mounts all libraries without ro: prefix when allLibraries is true", func() {
|
||||
result := buildAllowedPaths(nil, libraries, nil, true, true)
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1"))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3"))
|
||||
})
|
||||
|
||||
It("mounts only selected libraries without ro: prefix", func() {
|
||||
result := buildAllowedPaths(nil, libraries, []int{2}, false, true)
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("returns empty map when no libraries match", func() {
|
||||
result := buildAllowedPaths(nil, libraries, []int{99}, false, false)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty map when libraries list is empty", func() {
|
||||
result := buildAllowedPaths(nil, nil, []int{1}, false, false)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty map when allLibraries is false and no IDs provided", func() {
|
||||
result := buildAllowedPaths(nil, libraries, nil, false, false)
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,14 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error {
|
||||
return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest")
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduler permission requires SchedulerCallback capability
|
||||
if m.Permissions != nil && m.Permissions.Scheduler != nil {
|
||||
if !hasCapability(capabilities, CapabilityScheduler) {
|
||||
return fmt.Errorf("'scheduler' permission requires plugin to export '%s' function", FuncSchedulerCallback)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
47
plugins/migrate.go
Normal file
47
plugins/migrate.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// migrateDB applies schema migrations to a SQLite database.
|
||||
//
|
||||
// Each entry in migrations is a single SQL statement. The current schema version
|
||||
// is tracked using SQLite's built-in PRAGMA user_version. Only statements after
|
||||
// the current version are executed, within a single transaction.
|
||||
func migrateDB(db *sql.DB, migrations []string) error {
|
||||
var version int
|
||||
if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil {
|
||||
return fmt.Errorf("reading schema version: %w", err)
|
||||
}
|
||||
|
||||
if version >= len(migrations) {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting migration transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for i := version; i < len(migrations); i++ {
|
||||
if _, err := tx.Exec(migrations[i]); err != nil {
|
||||
return fmt.Errorf("migration %d failed: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// PRAGMA statements cannot be executed inside a transaction in some SQLite
|
||||
// drivers, but with mattn/go-sqlite3 this works. We set it inside the tx
|
||||
// so that a failed commit leaves the version unchanged.
|
||||
if _, err := tx.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, len(migrations))); err != nil {
|
||||
return fmt.Errorf("updating schema version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("committing migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
99
plugins/migrate_test.go
Normal file
99
plugins/migrate_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("migrateDB", func() {
|
||||
var db *sql.DB
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", ":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if db != nil {
|
||||
db.Close()
|
||||
}
|
||||
})
|
||||
|
||||
getUserVersion := func() int {
|
||||
var version int
|
||||
Expect(db.QueryRow(`PRAGMA user_version`).Scan(&version)).To(Succeed())
|
||||
return version
|
||||
}
|
||||
|
||||
It("applies all migrations on a fresh database", func() {
|
||||
migrations := []string{
|
||||
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
`ALTER TABLE test ADD COLUMN email TEXT`,
|
||||
}
|
||||
|
||||
Expect(migrateDB(db, migrations)).To(Succeed())
|
||||
Expect(getUserVersion()).To(Equal(2))
|
||||
|
||||
// Verify schema
|
||||
_, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("skips already applied migrations", func() {
|
||||
migrations1 := []string{
|
||||
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
}
|
||||
Expect(migrateDB(db, migrations1)).To(Succeed())
|
||||
Expect(getUserVersion()).To(Equal(1))
|
||||
|
||||
// Add a new migration
|
||||
migrations2 := []string{
|
||||
`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
`ALTER TABLE test ADD COLUMN email TEXT`,
|
||||
}
|
||||
Expect(migrateDB(db, migrations2)).To(Succeed())
|
||||
Expect(getUserVersion()).To(Equal(2))
|
||||
|
||||
// Verify the new column exists
|
||||
_, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("is a no-op when all migrations are applied", func() {
|
||||
migrations := []string{
|
||||
`CREATE TABLE test (id INTEGER PRIMARY KEY)`,
|
||||
}
|
||||
Expect(migrateDB(db, migrations)).To(Succeed())
|
||||
Expect(migrateDB(db, migrations)).To(Succeed())
|
||||
Expect(getUserVersion()).To(Equal(1))
|
||||
})
|
||||
|
||||
It("is a no-op with empty migrations slice", func() {
|
||||
Expect(migrateDB(db, nil)).To(Succeed())
|
||||
Expect(getUserVersion()).To(Equal(0))
|
||||
})
|
||||
|
||||
It("rolls back on failure", func() {
|
||||
migrations := []string{
|
||||
`CREATE TABLE test (id INTEGER PRIMARY KEY)`,
|
||||
`INVALID SQL STATEMENT`,
|
||||
}
|
||||
|
||||
err := migrateDB(db, migrations)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("migration 2 failed"))
|
||||
|
||||
// Version should remain 0 (rolled back)
|
||||
Expect(getUserVersion()).To(Equal(0))
|
||||
|
||||
// Table should not exist (rolled back)
|
||||
_, err = db.Exec(`INSERT INTO test (id) VALUES (1)`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -6,3 +6,10 @@ require (
|
||||
github.com/extism/go-pdk v1.1.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// HTTPRequest represents the HTTPRequest data structure.
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
@@ -23,6 +24,7 @@ type HTTPRequest struct {
|
||||
TimeoutMs int32 `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the HTTPResponse data structure.
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
@@ -35,11 +37,11 @@ type HTTPResponse struct {
|
||||
//go:wasmimport extism:host/user http_send
|
||||
func http_send(uint64) uint64
|
||||
|
||||
type httpSendRequest struct {
|
||||
type hTTPSendRequest struct {
|
||||
Request HTTPRequest `json:"request"`
|
||||
}
|
||||
|
||||
type httpSendResponse struct {
|
||||
type hTTPSendResponse struct {
|
||||
Result *HTTPResponse `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
@@ -55,7 +57,7 @@ type httpSendResponse struct {
|
||||
// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
func HTTPSend(request HTTPRequest) (*HTTPResponse, error) {
|
||||
// Marshal request to JSON
|
||||
req := httpSendRequest{
|
||||
req := hTTPSendRequest{
|
||||
Request: request,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
@@ -73,7 +75,7 @@ func HTTPSend(request HTTPRequest) (*HTTPResponse, error) {
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response httpSendResponse
|
||||
var response hTTPSendResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package host
|
||||
|
||||
import "github.com/stretchr/testify/mock"
|
||||
|
||||
// HTTPRequest represents the HTTPRequest data structure.
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
@@ -19,6 +20,7 @@ type HTTPRequest struct {
|
||||
TimeoutMs int32 `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the HTTPResponse data structure.
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
@@ -19,15 +19,20 @@ import (
|
||||
//go:wasmimport extism:host/user kvstore_set
|
||||
func kvstore_set(uint64) uint64
|
||||
|
||||
// kvstore_setwithttl is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user kvstore_setwithttl
|
||||
func kvstore_setwithttl(uint64) uint64
|
||||
|
||||
// kvstore_get is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user kvstore_get
|
||||
func kvstore_get(uint64) uint64
|
||||
|
||||
// kvstore_delete is the host function provided by Navidrome.
|
||||
// kvstore_getmany is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user kvstore_delete
|
||||
func kvstore_delete(uint64) uint64
|
||||
//go:wasmimport extism:host/user kvstore_getmany
|
||||
func kvstore_getmany(uint64) uint64
|
||||
|
||||
// kvstore_has is the host function provided by Navidrome.
|
||||
//
|
||||
@@ -39,6 +44,16 @@ func kvstore_has(uint64) uint64
|
||||
//go:wasmimport extism:host/user kvstore_list
|
||||
func kvstore_list(uint64) uint64
|
||||
|
||||
// kvstore_delete is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user kvstore_delete
|
||||
func kvstore_delete(uint64) uint64
|
||||
|
||||
// kvstore_deletebyprefix is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user kvstore_deletebyprefix
|
||||
func kvstore_deletebyprefix(uint64) uint64
|
||||
|
||||
// kvstore_getstorageused is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user kvstore_getstorageused
|
||||
@@ -49,6 +64,12 @@ type kVStoreSetRequest struct {
|
||||
Value []byte `json:"value"`
|
||||
}
|
||||
|
||||
type kVStoreSetWithTTLRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value []byte `json:"value"`
|
||||
TtlSeconds int64 `json:"ttlSeconds"`
|
||||
}
|
||||
|
||||
type kVStoreGetRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
@@ -59,8 +80,13 @@ type kVStoreGetResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type kVStoreDeleteRequest struct {
|
||||
Key string `json:"key"`
|
||||
type kVStoreGetManyRequest struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type kVStoreGetManyResponse struct {
|
||||
Values map[string][]byte `json:"values,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type kVStoreHasRequest struct {
|
||||
@@ -81,6 +107,19 @@ type kVStoreListResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type kVStoreDeleteRequest struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type kVStoreDeleteByPrefixRequest struct {
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
type kVStoreDeleteByPrefixResponse struct {
|
||||
DeletedCount int64 `json:"deletedCount,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type kVStoreGetStorageUsedResponse struct {
|
||||
Bytes int64 `json:"bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -127,6 +166,52 @@ func KVStoreSet(key string, value []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// KVStoreSetWithTTL calls the kvstore_setwithttl host function.
|
||||
// SetWithTTL stores a byte value with the given key and a time-to-live.
|
||||
//
|
||||
// After ttlSeconds, the key is treated as non-existent and will be
|
||||
// cleaned up lazily. ttlSeconds must be greater than 0.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key (max 256 bytes, UTF-8)
|
||||
// - value: The byte slice to store
|
||||
// - ttlSeconds: Time-to-live in seconds (must be > 0)
|
||||
//
|
||||
// Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error {
|
||||
// Marshal request to JSON
|
||||
req := kVStoreSetWithTTLRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
TtlSeconds: ttlSeconds,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := kvstore_setwithttl(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Error != "" {
|
||||
return errors.New(response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KVStoreGet calls the kvstore_get host function.
|
||||
// Get retrieves a byte value from storage.
|
||||
//
|
||||
@@ -167,43 +252,45 @@ func KVStoreGet(key string) ([]byte, bool, error) {
|
||||
return response.Value, response.Exists, nil
|
||||
}
|
||||
|
||||
// KVStoreDelete calls the kvstore_delete host function.
|
||||
// Delete removes a value from storage.
|
||||
// KVStoreGetMany calls the kvstore_getmany host function.
|
||||
// GetMany retrieves multiple values in a single call.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key
|
||||
// - keys: The storage keys to retrieve
|
||||
//
|
||||
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
func KVStoreDelete(key string) error {
|
||||
// Returns a map of key to value for keys that exist and have not expired.
|
||||
// Missing or expired keys are omitted from the result.
|
||||
func KVStoreGetMany(keys []string) (map[string][]byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := kVStoreDeleteRequest{
|
||||
Key: key,
|
||||
req := kVStoreGetManyRequest{
|
||||
Keys: keys,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := kvstore_delete(reqMem.Offset())
|
||||
responsePtr := kvstore_getmany(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
// Parse the response
|
||||
var response kVStoreGetManyResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return errors.New(response.Error)
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
return nil
|
||||
|
||||
return response.Values, nil
|
||||
}
|
||||
|
||||
// KVStoreHas calls the kvstore_has host function.
|
||||
@@ -286,6 +373,85 @@ func KVStoreList(prefix string) ([]string, error) {
|
||||
return response.Keys, nil
|
||||
}
|
||||
|
||||
// KVStoreDelete calls the kvstore_delete host function.
|
||||
// Delete removes a value from storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key
|
||||
//
|
||||
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
func KVStoreDelete(key string) error {
|
||||
// Marshal request to JSON
|
||||
req := kVStoreDeleteRequest{
|
||||
Key: key,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := kvstore_delete(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Error != "" {
|
||||
return errors.New(response.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KVStoreDeleteByPrefix calls the kvstore_deletebyprefix host function.
|
||||
// DeleteByPrefix removes all keys matching the given prefix.
|
||||
//
|
||||
// Parameters:
|
||||
// - prefix: Key prefix to match (must not be empty)
|
||||
//
|
||||
// Returns the number of keys deleted. Includes expired keys.
|
||||
func KVStoreDeleteByPrefix(prefix string) (int64, error) {
|
||||
// Marshal request to JSON
|
||||
req := kVStoreDeleteByPrefixRequest{
|
||||
Prefix: prefix,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := kvstore_deletebyprefix(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response kVStoreDeleteByPrefixResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return 0, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return response.DeletedCount, nil
|
||||
}
|
||||
|
||||
// KVStoreGetStorageUsed calls the kvstore_getstorageused host function.
|
||||
// GetStorageUsed returns the total storage used by this plugin in bytes.
|
||||
func KVStoreGetStorageUsed() (int64, error) {
|
||||
|
||||
@@ -37,6 +37,28 @@ func KVStoreSet(key string, value []byte) error {
|
||||
return KVStoreMock.Set(key, value)
|
||||
}
|
||||
|
||||
// SetWithTTL is the mock method for KVStoreSetWithTTL.
|
||||
func (m *mockKVStoreService) SetWithTTL(key string, value []byte, ttlSeconds int64) error {
|
||||
args := m.Called(key, value, ttlSeconds)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// KVStoreSetWithTTL delegates to the mock instance.
|
||||
// SetWithTTL stores a byte value with the given key and a time-to-live.
|
||||
//
|
||||
// After ttlSeconds, the key is treated as non-existent and will be
|
||||
// cleaned up lazily. ttlSeconds must be greater than 0.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key (max 256 bytes, UTF-8)
|
||||
// - value: The byte slice to store
|
||||
// - ttlSeconds: Time-to-live in seconds (must be > 0)
|
||||
//
|
||||
// Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error {
|
||||
return KVStoreMock.SetWithTTL(key, value, ttlSeconds)
|
||||
}
|
||||
|
||||
// Get is the mock method for KVStoreGet.
|
||||
func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) {
|
||||
args := m.Called(key)
|
||||
@@ -54,21 +76,22 @@ func KVStoreGet(key string) ([]byte, bool, error) {
|
||||
return KVStoreMock.Get(key)
|
||||
}
|
||||
|
||||
// Delete is the mock method for KVStoreDelete.
|
||||
func (m *mockKVStoreService) Delete(key string) error {
|
||||
args := m.Called(key)
|
||||
return args.Error(0)
|
||||
// GetMany is the mock method for KVStoreGetMany.
|
||||
func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) {
|
||||
args := m.Called(keys)
|
||||
return args.Get(0).(map[string][]byte), args.Error(1)
|
||||
}
|
||||
|
||||
// KVStoreDelete delegates to the mock instance.
|
||||
// Delete removes a value from storage.
|
||||
// KVStoreGetMany delegates to the mock instance.
|
||||
// GetMany retrieves multiple values in a single call.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key
|
||||
// - keys: The storage keys to retrieve
|
||||
//
|
||||
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
func KVStoreDelete(key string) error {
|
||||
return KVStoreMock.Delete(key)
|
||||
// Returns a map of key to value for keys that exist and have not expired.
|
||||
// Missing or expired keys are omitted from the result.
|
||||
func KVStoreGetMany(keys []string) (map[string][]byte, error) {
|
||||
return KVStoreMock.GetMany(keys)
|
||||
}
|
||||
|
||||
// Has is the mock method for KVStoreHas.
|
||||
@@ -105,6 +128,40 @@ func KVStoreList(prefix string) ([]string, error) {
|
||||
return KVStoreMock.List(prefix)
|
||||
}
|
||||
|
||||
// Delete is the mock method for KVStoreDelete.
|
||||
func (m *mockKVStoreService) Delete(key string) error {
|
||||
args := m.Called(key)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// KVStoreDelete delegates to the mock instance.
|
||||
// Delete removes a value from storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - key: The storage key
|
||||
//
|
||||
// Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
func KVStoreDelete(key string) error {
|
||||
return KVStoreMock.Delete(key)
|
||||
}
|
||||
|
||||
// DeleteByPrefix is the mock method for KVStoreDeleteByPrefix.
|
||||
func (m *mockKVStoreService) DeleteByPrefix(prefix string) (int64, error) {
|
||||
args := m.Called(prefix)
|
||||
return args.Get(0).(int64), args.Error(1)
|
||||
}
|
||||
|
||||
// KVStoreDeleteByPrefix delegates to the mock instance.
|
||||
// DeleteByPrefix removes all keys matching the given prefix.
|
||||
//
|
||||
// Parameters:
|
||||
// - prefix: Key prefix to match (must not be empty)
|
||||
//
|
||||
// Returns the number of keys deleted. Includes expired keys.
|
||||
func KVStoreDeleteByPrefix(prefix string) (int64, error) {
|
||||
return KVStoreMock.DeleteByPrefix(prefix)
|
||||
}
|
||||
|
||||
// GetStorageUsed is the mock method for KVStoreGetStorageUsed.
|
||||
func (m *mockKVStoreService) GetStorageUsed() (int64, error) {
|
||||
args := m.Called()
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -38,6 +37,12 @@ type subsonicAPICallRawRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
type subsonicAPICallRawResponse struct {
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Data []byte `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SubsonicAPICall calls the subsonicapi_call host function.
|
||||
// Call executes a Subsonic API request and returns the JSON response.
|
||||
//
|
||||
@@ -78,8 +83,8 @@ func SubsonicAPICall(uri string) (string, error) {
|
||||
|
||||
// SubsonicAPICallRaw calls the subsonicapi_callraw host function.
|
||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
// Designed for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := subsonicAPICallRawRequest{
|
||||
@@ -99,22 +104,16 @@ func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
// Parse the response
|
||||
var response subsonicAPICallRawResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return "", nil, errors.New(response.Error)
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
|
||||
return response.ContentType, response.Data, nil
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
|
||||
|
||||
// SubsonicAPICallRaw delegates to the mock instance.
|
||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
// Designed for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
return SubsonicAPIMock.CallRaw(uri)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type OnBinaryMessageRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Data is the binary data received from the WebSocket, encoded as base64.
|
||||
Data string `json:"data"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// OnCloseRequest is the request provided when a WebSocket connection is closed.
|
||||
|
||||
@@ -13,7 +13,7 @@ type OnBinaryMessageRequest struct {
|
||||
// ConnectionID is the unique identifier for the WebSocket connection that received the message.
|
||||
ConnectionID string `json:"connectionId"`
|
||||
// Data is the binary data received from the WebSocket, encoded as base64.
|
||||
Data string `json:"data"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
|
||||
// OnCloseRequest is the request provided when a WebSocket connection is closed.
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -337,7 +338,7 @@ Returns an error if the operation fails.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value": base64.b64encode(value).decode("ascii"),
|
||||
"ttlSeconds": ttl_seconds,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
@@ -382,7 +383,7 @@ or the stored value is not a byte slice, exists will be false.
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return CacheGetBytesResult(
|
||||
value=response.get("value", b""),
|
||||
value=base64.b64decode(response.get("value", "")),
|
||||
exists=response.get("exists", False),
|
||||
)
|
||||
|
||||
|
||||
60
plugins/pdk/python/host/nd_host_http.py
Normal file
60
plugins/pdk/python/host/nd_host_http.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the HTTP host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "http_send")
|
||||
def _http_send(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def http_send(request: Any) -> Any:
|
||||
"""Send executes an HTTP request and returns the response.
|
||||
|
||||
Parameters:
|
||||
- request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
|
||||
Returns the HTTP response with status code, headers, and body.
|
||||
Network errors, timeouts, and permission failures are returned as Go errors.
|
||||
Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
|
||||
Args:
|
||||
request: Any parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"request": request,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _http_send(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", None)
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -25,14 +26,20 @@ def _kvstore_set(offset: int) -> int:
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "kvstore_setwithttl")
|
||||
def _kvstore_setwithttl(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "kvstore_get")
|
||||
def _kvstore_get(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "kvstore_delete")
|
||||
def _kvstore_delete(offset: int) -> int:
|
||||
@extism.import_fn("extism:host/user", "kvstore_getmany")
|
||||
def _kvstore_getmany(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
@@ -49,6 +56,18 @@ def _kvstore_list(offset: int) -> int:
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "kvstore_delete")
|
||||
def _kvstore_delete(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "kvstore_deletebyprefix")
|
||||
def _kvstore_deletebyprefix(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "kvstore_getstorageused")
|
||||
def _kvstore_getstorageused(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
@@ -80,7 +99,7 @@ Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value": base64.b64encode(value).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
@@ -93,6 +112,43 @@ Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
|
||||
|
||||
|
||||
def kvstore_set_with_ttl(key: str, value: bytes, ttl_seconds: int) -> None:
|
||||
"""SetWithTTL stores a byte value with the given key and a time-to-live.
|
||||
|
||||
After ttlSeconds, the key is treated as non-existent and will be
|
||||
cleaned up lazily. ttlSeconds must be greater than 0.
|
||||
|
||||
Parameters:
|
||||
- key: The storage key (max 256 bytes, UTF-8)
|
||||
- value: The byte slice to store
|
||||
- ttlSeconds: Time-to-live in seconds (must be > 0)
|
||||
|
||||
Returns an error if the storage limit would be exceeded or the operation fails.
|
||||
|
||||
Args:
|
||||
key: str parameter.
|
||||
value: bytes parameter.
|
||||
ttl_seconds: int parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
"value": base64.b64encode(value).decode("ascii"),
|
||||
"ttlSeconds": ttl_seconds,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _kvstore_setwithttl(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
|
||||
|
||||
def kvstore_get(key: str) -> KVStoreGetResult:
|
||||
"""Get retrieves a byte value from storage.
|
||||
|
||||
@@ -123,37 +179,42 @@ Returns the value and whether the key exists.
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return KVStoreGetResult(
|
||||
value=response.get("value", b""),
|
||||
value=base64.b64decode(response.get("value", "")),
|
||||
exists=response.get("exists", False),
|
||||
)
|
||||
|
||||
|
||||
def kvstore_delete(key: str) -> None:
|
||||
"""Delete removes a value from storage.
|
||||
def kvstore_get_many(keys: Any) -> Any:
|
||||
"""GetMany retrieves multiple values in a single call.
|
||||
|
||||
Parameters:
|
||||
- key: The storage key
|
||||
- keys: The storage keys to retrieve
|
||||
|
||||
Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
Returns a map of key to value for keys that exist and have not expired.
|
||||
Missing or expired keys are omitted from the result.
|
||||
|
||||
Args:
|
||||
key: str parameter.
|
||||
keys: Any parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
"keys": keys,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _kvstore_delete(request_mem.offset)
|
||||
response_offset = _kvstore_getmany(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("values", None)
|
||||
|
||||
|
||||
def kvstore_has(key: str) -> bool:
|
||||
@@ -220,6 +281,66 @@ Returns a slice of matching keys.
|
||||
return response.get("keys", None)
|
||||
|
||||
|
||||
def kvstore_delete(key: str) -> None:
|
||||
"""Delete removes a value from storage.
|
||||
|
||||
Parameters:
|
||||
- key: The storage key
|
||||
|
||||
Returns an error if the operation fails. Does not return an error if the key doesn't exist.
|
||||
|
||||
Args:
|
||||
key: str parameter.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"key": key,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _kvstore_delete(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
|
||||
|
||||
def kvstore_delete_by_prefix(prefix: str) -> int:
|
||||
"""DeleteByPrefix removes all keys matching the given prefix.
|
||||
|
||||
Parameters:
|
||||
- prefix: Key prefix to match (must not be empty)
|
||||
|
||||
Returns the number of keys deleted. Includes expired keys.
|
||||
|
||||
Args:
|
||||
prefix: str parameter.
|
||||
|
||||
Returns:
|
||||
int: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"prefix": prefix,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _kvstore_deletebyprefix(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("deletedCount", 0)
|
||||
|
||||
|
||||
def kvstore_get_storage_used() -> int:
|
||||
"""GetStorageUsed returns the total storage used by this plugin in bytes.
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Tuple
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import struct
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -32,6 +32,13 @@ def _subsonicapi_callraw(offset: int) -> int:
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubsonicAPICallRawResult:
|
||||
"""Result type for subsonicapi_call_raw."""
|
||||
content_type: str
|
||||
data: bytes
|
||||
|
||||
|
||||
def subsonicapi_call(uri: str) -> str:
|
||||
"""Call executes a Subsonic API request and returns the JSON response.
|
||||
|
||||
@@ -62,16 +69,16 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
|
||||
return response.get("responseJson", "")
|
||||
|
||||
|
||||
def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]:
|
||||
def subsonicapi_call_raw(uri: str) -> SubsonicAPICallRawResult:
|
||||
"""CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
Optimized for binary endpoints like getCoverArt and stream that return
|
||||
non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
Designed for binary endpoints like getCoverArt and stream that return
|
||||
non-JSON data. The data is base64-encoded over JSON on the wire.
|
||||
|
||||
Args:
|
||||
uri: str parameter.
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
SubsonicAPICallRawResult containing content_type, data,.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
@@ -83,19 +90,12 @@ non-JSON data. The response is returned as raw bytes without JSON encoding overh
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _subsonicapi_callraw(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response_bytes = response_mem.bytes()
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return SubsonicAPICallRawResult(
|
||||
content_type=response.get("contentType", ""),
|
||||
data=base64.b64decode(response.get("data", "")),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
import base64
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -134,7 +135,7 @@ Returns an error if the connection is not found or if sending fails.
|
||||
"""
|
||||
request = {
|
||||
"connectionId": connection_id,
|
||||
"data": data,
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
|
||||
@@ -11,6 +11,7 @@ path = "src/lib.rs"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
extism-pdk = "1.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -4,6 +4,29 @@
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
mod base64_bytes {
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&BASE64.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
BASE64.decode(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for skip_serializing_if with numeric types
|
||||
#[allow(dead_code)]
|
||||
@@ -27,7 +50,8 @@ pub struct OnBinaryMessageRequest {
|
||||
pub connection_id: String,
|
||||
/// Data is the binary data received from the WebSocket, encoded as base64.
|
||||
#[serde(default)]
|
||||
pub data: String,
|
||||
#[serde(with = "base64_bytes")]
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
/// OnCloseRequest is the request provided when a WebSocket connection is closed.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
|
||||
@@ -11,6 +11,7 @@ readme = "README.md"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
extism-pdk = "1.2"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user