Compare commits

...

13 Commits

Author SHA1 Message Date
Kendall Garner
2731e25fd2 fix(ui): use div for fragment, check lastfm url for artist page (#4980)
* fix(ui): use div for fragment, check lastfm url for artist page

* use span instead of div for better compat

* fix: implement isLastFmURL utility and add tests for URL validation

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2026-02-04 17:34:26 -05:00
Boris Rorsvort
4f3845bbe3 fix(ui): Nautiline theme font path (#4983)
* fix: Nautiline theme font path

* refactor font path
2026-02-04 17:24:30 -05:00
Deluan Quintão
e8863ed147 feat(plugins): add SubsonicAPI CallRaw, with support for raw=true binary response for host functions (#4982)
* feat: implement raw binary framing for host function responses

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add CallRaw method for Subsonic API to handle binary responses

Signed-off-by: Deluan <deluan@navidrome.org>

* test: add tests for raw=true methods and binary framing generation

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: improve error message for malformed raw responses to indicate incomplete header

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: add wasm_import_module attribute for raw methods and improve content-type handling

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-04 15:48:08 -05:00
dependabot[bot]
19ea338bed chore(deps): bump @isaacs/brace-expansion from 5.0.0 to 5.0.1 in /ui (#4974)
Bumps @isaacs/brace-expansion from 5.0.0 to 5.0.1.

---
updated-dependencies:
- dependency-name: "@isaacs/brace-expansion"
  dependency-version: 5.0.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 10:12:00 -05:00
dependabot[bot]
338853468f chore(deps): bump bytes in /plugins/pdk/rust/nd-pdk-host (#4973)
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-04 10:11:37 -05:00
Deluan
4e720ee931 fix: handle WASM runtime panics in gotaglib openFile function.
see #4977

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 22:56:47 -05:00
dependabot[bot]
0c8f2a559c chore(deps): bump lodash from 4.17.21 to 4.17.23 in /ui (#4922)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-03 13:12:53 -05:00
Deluan Quintão
a1036e75a9 fix(ui): update Catalan, German, Spanish, French, Indonesian, Polish translations from POEditor (#4960)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2026-02-03 12:50:16 -05:00
Deluan
2829cec0ce fix(subsonic): add SubMusic to default MinimalClients list
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 12:46:39 -05:00
Deluan
ddff5db14a chore: format JSX components
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 12:46:39 -05:00
Alex Gustafsson
d7ec7355c9 Merge commit from fork
* Rework frontend code interacting directly with DOM

Rework frontend code that uses user-supplied data to render things like
comments and notes. In places where using React's built-in sanitization
is possible, the feature is used. In other places, where some markup
might be necessary, DOMPurify is used to sanitize the HTML before
rendering it.

Solves: GHSA-rh3r-8pxm-hg4w

* Remove test post DOM rework

* fixup! Rework frontend code interacting directly with DOM
2026-02-03 12:22:57 -05:00
Deluan
c3a4585c83 chore(plugins): move Discord Rich Presence plugin to its own repository: https://github.com/navidrome/discord-rich-presence-plugin 2026-02-03 11:41:49 -05:00
Deluan
2068e7d413 fix(plugins): don't recording metrics for not implemented plugin calls
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-03 10:15:12 -05:00
60 changed files with 1472 additions and 1627 deletions

View File

@@ -13,12 +13,15 @@ package gotaglib
import (
"errors"
"fmt"
"io"
"io/fs"
"runtime/debug"
"strings"
"time"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"go.senan.xyz/taglib"
)
@@ -94,7 +97,17 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
// openFile opens the file at filePath using the extractor's filesystem.
// It returns a TagLib File handle and a cleanup function to close resources.
func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
}
}()
// Open the file from the filesystem
file, err := e.fs.Open(filePath)
if err != nil {
@@ -105,12 +118,12 @@ func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
f, err = taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
if err != nil {
file.Close()
return nil, nil, err
}
closeFunc := func() {
closeFunc = func() {
f.Close()
file.Close()
}

View File

@@ -643,7 +643,8 @@ func setViperDefaults() {
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
viper.SetDefault("subsonic.legacyclients", "DSub")
viper.SetDefault("subsonic.minimalclients", "SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)

View File

@@ -1036,9 +1036,8 @@ See [examples/](examples/) for complete working plugins:
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
---

View File

@@ -282,6 +282,9 @@ 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() {

View File

@@ -264,6 +264,96 @@ 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{
@@ -626,6 +716,72 @@ var _ = Describe("Generator", func() {
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
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() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateClientPython(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
Expect(codeStr).NotTo(ContainSubstring("import struct"))
})
})
Describe("GenerateGoDoc", func() {
@@ -782,6 +938,47 @@ var _ = Describe("Generator", func() {
// Check for PDK import
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() {
@@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() {
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
})
It("should generate raw extern C import and 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 := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
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 NOT generate response type for raw methods
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
// Should generate request type (request is still JSON)
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
// Should return Result<(String, Vec<u8>), Error>
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
// 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"))
})
})
})

View File

@@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
m := Method{
Name: name,
ExportName: annotation["name"],
Raw: annotation["raw"] == "true",
Doc: doc,
}
@@ -799,6 +800,13 @@ 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
}

View File

@@ -122,6 +122,119 @@ 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

View File

@@ -8,6 +8,9 @@
package {{.Package}}
import (
{{- if .Service.HasRawMethods}}
"encoding/binary"
{{- end}}
"encoding/json"
{{- if .Service.HasErrors}}
"errors"
@@ -49,7 +52,7 @@ type {{requestType .}} struct {
{{- end}}
}
{{- end}}
{{- if not .IsErrorOnly}}
{{- if and (not .IsErrorOnly) (not .Raw)}}
type {{responseType .}} struct {
{{- range .Returns}}
@@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
{{- if .IsErrorOnly}}
{{- 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}}
// Parse error-only response
var response struct {

View File

@@ -8,10 +8,13 @@
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
import extism
import json
{{- if .Service.HasRawMethods}}
import struct
{{- end}}
class HostFunctionError(Exception):
@@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
{{- end}}
{{- /* Generate dataclasses for multi-value returns */ -}}
{{range .Service.Methods}}
{{- if .NeedsResultClass}}
{{- if and .NeedsResultClass (not .Raw)}}
@dataclass
@@ -44,7 +47,7 @@ class {{pythonResultType .}}:
{{range .Service.Methods}}
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}}:
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}}:
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
{{- if .HasParams}}
@@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
{{.PythonName}}: {{.PythonType}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
{{- if .Raw}}
Returns:
Tuple of (content_type, data) with the raw binary response.
{{- else if .HasReturns}}
Returns:
{{- if .NeedsResultClass}}
@@ -79,6 +86,24 @@ 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"):
@@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
{{- end}}
{{- end}}
{{- end}}

View File

@@ -33,6 +33,7 @@ struct {{requestType .}} {
{{- end}}
}
{{- end}}
{{- if not .Raw}}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -47,16 +48,92 @@ 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}}
@@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
}
{{- end}}
{{- end}}
{{- end}}

View File

@@ -4,6 +4,9 @@ package {{.Package}}
import (
"context"
{{- if .Service.HasRawMethods}}
"encoding/binary"
{{- end}}
"encoding/json"
extism "github.com/extism/go-sdk"
@@ -20,6 +23,7 @@ type {{requestType .}} struct {
{{- end}}
}
{{- end}}
{{- if not .Raw}}
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
type {{responseType .}} struct {
@@ -30,6 +34,7 @@ type {{responseType .}} struct {
Error string `json:"error,omitempty"`
{{- end}}
}
{{- end}}
{{end}}
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
@@ -51,18 +56,48 @@ 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 .HasReturns}}
{{- 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 .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 {
@@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
{{- else}}
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
{{- end}}
// Write JSON response to plugin memory
resp := {{responseType .}}{
@@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
{{- end}}
}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else if .HasError}}
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := {{responseType .}}{}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- else}}
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
// Write JSON response to plugin memory
resp := {{responseType .}}{}
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
{{- end}}
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
@@ -119,3 +162,16 @@ 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}}

View File

@@ -173,6 +173,16 @@ 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")
@@ -181,6 +191,7 @@ 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.

View File

@@ -0,0 +1,66 @@
// 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
}

View File

@@ -0,0 +1,63 @@
# 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

View File

@@ -0,0 +1,73 @@
// 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))
}

View File

@@ -0,0 +1,10 @@
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)
}

View File

@@ -9,7 +9,6 @@ This folder contains example plugins demonstrating various capabilities and lang
| [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure |
| [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata |
| [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) |
| [discord-rich-presence](discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration |
| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger |
| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble |
@@ -37,7 +36,7 @@ This creates `.ndp` package files for each plugin.
```bash
make minimal.ndp
make wikimedia.ndp
make discord-rich-presence.ndp
make discord-rich-presence-rs.ndp
```
### Clean

View File

@@ -29,7 +29,7 @@ This plugin implements multiple capabilities to demonstrate the nd-pdk library:
## Configuration
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence-rs):
| Key | Description | Example |
|---------------|--------------------------------------|---------------------------|

View File

@@ -1,135 +0,0 @@
# Discord Rich Presence Plugin
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time connection to an external service while remaining completely stateless. This plugin is based on the [Navicord](https://github.com/logixism/navicord) project, which provides similar functionality.
**⚠️ WARNING: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the Navidrome configuration file, which is not secure and may be against Discord's terms of service. Use it at your own risk.**
## Overview
The plugin exposes three capabilities:
- **Scrobbler** receives `NowPlaying` notifications from Navidrome
- **WebSocketCallback** handles Discord gateway messages
- **SchedulerCallback** used to clear presence and send periodic heartbeats
It relies on several host services declared in the manifest:
- `http` queries Discord API endpoints
- `websocket` maintains gateway connections
- `scheduler` schedules heartbeats and presence cleanup
- `cache` stores sequence numbers for heartbeats
- `artwork` resolves track artwork URLs
## Architecture
The plugin registers capabilities using the PDK Register pattern:
```go
import (
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
type discordPlugin struct{}
func init() {
scrobbler.Register(&discordPlugin{})
scheduler.Register(&discordPlugin{})
websocket.Register(&discordPlugin{})
}
```
The PDK generates the appropriate export wrappers automatically.
When `NowPlaying` is invoked the plugin:
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
2. Connects to Discord using `WebSocketService` if no connection exists.
3. Sends the activity payload with track details and artwork.
4. Schedules a one-time callback to clear the presence after the track finishes.
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in `CacheService` to remain available across plugin instances.
The scheduler callback uses the `payload` field to route to the appropriate handler:
- `"heartbeat"` sends a heartbeat to Discord (recurring)
- `"clear-activity"` clears the presence and disconnects (one-time)
## Stateless Operation
Navidrome plugins are completely stateless each method call instantiates a new plugin instance and discards it afterwards.
To work within this model the plugin stores no in-memory state. Connections are keyed by username inside the host services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every method call.
## Configuration
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
| Key | Description | Example |
|---------------|-------------------------------------------|--------------------------------|
| `clientid` | Your Discord application ID | `123456789012345678` |
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
Each user is configured as a separate key with the `user.` prefix.
## Building
From the `plugins/examples/` directory:
```sh
make discord-rich-presence.ndp
```
Or manually:
```sh
cd discord-rich-presence
tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm .
zip -j discord-rich-presence.ndp manifest.json plugin.wasm
```
## Installation
Place the resulting `discord-rich-presence.ndp` in your Navidrome plugins folder and enable plugins in your configuration:
```toml
[Plugins]
Enabled = true
Folder = "/path/to/plugins"
```
## Files
| File | Description |
|-----------|------------------------------------------------------------------|
| `main.go` | Plugin entry point, capability registration, and implementations |
| `rpc.go` | Discord gateway communication and RPC logic |
| `go.mod` | Go module file |
## PDK
This plugin imports the Navidrome PDK subpackages directly:
```go
import (
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
```
The `go.mod` file uses `replace` directives to point to the local packages for development.
## Host Services Used
| Service | Purpose |
|-----------|------------------------------------------------------------------|
| Cache | Store Discord sequence numbers and processed image URLs |
| Scheduler | Schedule heartbeats (recurring) and activity clearing (one-time) |
| WebSocket | Maintain persistent connection to Discord gateway |
| Artwork | Get track artwork URLs for rich presence display |
## Implementation Details
See `main.go` and `rpc.go` for the complete implementation.

View File

@@ -1,32 +0,0 @@
module discord-rich-presence
go 1.25
require (
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/stretchr/testify v1.11.1
)
require (
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

View File

@@ -1,73 +0,0 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,219 +0,0 @@
// Discord Rich Presence Plugin for Navidrome
//
// This plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can
// keep a real-time connection to an external service while remaining completely stateless.
//
// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback
//
// NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord
// token being stored in the Navidrome configuration file, which is not secure and may be
// against Discord's terms of service. Use it at your own risk.
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
// Configuration keys
const (
clientIDKey = "clientid"
usersKey = "users"
)
// userToken represents a user-token mapping from the config
type userToken struct {
Username string `json:"username"`
Token string `json:"token"`
}
// discordPlugin implements the scrobbler and scheduler interfaces.
type discordPlugin struct{}
// rpc handles Discord gateway communication (via websockets).
var rpc = &discordRPC{}
// init registers the plugin capabilities
func init() {
scrobbler.Register(&discordPlugin{})
scheduler.Register(&discordPlugin{})
websocket.Register(rpc)
}
// getConfig loads the plugin configuration.
func getConfig() (clientID string, users map[string]string, err error) {
clientID, ok := pdk.GetConfig(clientIDKey)
if !ok || clientID == "" {
pdk.Log(pdk.LogWarn, "missing ClientID in configuration")
return "", nil, nil
}
// Get the users array from config
usersJSON, ok := pdk.GetConfig(usersKey)
if !ok || usersJSON == "" {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Parse the JSON array
var userTokens []userToken
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
return clientID, nil, nil
}
if len(userTokens) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Build the users map
users = make(map[string]string)
for _, ut := range userTokens {
if ut.Username != "" && ut.Token != "" {
users[ut.Username] = ut.Token
}
}
if len(users) == 0 {
pdk.Log(pdk.LogWarn, "no valid users configured")
return clientID, nil, nil
}
return clientID, users, nil
}
// getImageURL retrieves the track artwork URL.
func getImageURL(trackID string) string {
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
return ""
}
// Don't use localhost URLs
if strings.HasPrefix(artworkURL, "http://localhost") {
return ""
}
return artworkURL
}
// ============================================================================
// Scrobbler Implementation
// ============================================================================
// IsAuthorized checks if a user is authorized for Discord Rich Presence.
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) {
_, users, err := getConfig()
if err != nil {
return false, fmt.Errorf("failed to check user authorization: %w", err)
}
_, authorized := users[input.Username]
pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized))
return authorized, nil
}
// NowPlaying sends a now playing notification to Discord.
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title))
// Load configuration
clientID, users, err := getConfig()
if err != nil {
return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err)
}
// Check authorization
userToken, authorized := users[input.Username]
if !authorized {
return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username)
}
// Connect to Discord
if err := rpc.connect(input.Username, userToken); err != nil {
return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err)
}
// Cancel any existing completion schedule
_ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username))
// Calculate timestamps
now := time.Now().Unix()
startTime := (now - int64(input.Position)) * 1000
endTime := startTime + int64(input.Track.Duration)*1000
// Send activity update
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
Application: clientID,
Name: "Navidrome",
Type: 2, // Listening
Details: input.Track.Title,
State: input.Track.Artist,
Timestamps: activityTimestamps{
Start: startTime,
End: endTime,
},
Assets: activityAssets{
LargeImage: getImageURL(input.Track.ID),
LargeText: input.Track.Album,
},
}); err != nil {
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
}
// Schedule a timer to clear the activity after the track completes
remainingSeconds := int32(input.Track.Duration) - input.Position + 5
_, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username))
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err))
}
return nil
}
// Scrobble handles scrobble requests (no-op for Discord).
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error {
// Discord Rich Presence doesn't need scrobble events
return nil
}
// ============================================================================
// Scheduler Callback Implementation
// ============================================================================
// OnCallback handles scheduler callbacks.
func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring))
// Route based on payload
switch input.Payload {
case payloadHeartbeat:
// Heartbeat callback - scheduleId is the username
if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil {
return err
}
case payloadClearActivity:
// Clear activity callback - scheduleId is "username-clear"
username := strings.TrimSuffix(input.ScheduleID, "-clear")
if err := rpc.handleClearActivityCallback(username); err != nil {
return err
}
default:
pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload))
}
return nil
}
func main() {}

View File

@@ -1,227 +0,0 @@
package main
import (
"errors"
"strings"
"testing"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestDiscordPlugin(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Discord Plugin Main Suite")
}
var _ = Describe("discordPlugin", func() {
var plugin discordPlugin
BeforeEach(func() {
plugin = discordPlugin{}
pdk.ResetMock()
host.CacheMock.ExpectedCalls = nil
host.CacheMock.Calls = nil
host.ConfigMock.ExpectedCalls = nil
host.ConfigMock.Calls = nil
host.WebSocketMock.ExpectedCalls = nil
host.WebSocketMock.Calls = nil
host.SchedulerMock.ExpectedCalls = nil
host.SchedulerMock.Calls = nil
host.ArtworkMock.ExpectedCalls = nil
host.ArtworkMock.Calls = nil
})
Describe("getConfig", func() {
It("returns config values when properly set", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.user1", "user.user2"})
host.ConfigMock.On("Get", "user.user1").Return("token1", true)
host.ConfigMock.On("Get", "user.user2").Return("token2", true)
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
clientID, users, err := getConfig()
Expect(err).ToNot(HaveOccurred())
Expect(clientID).To(Equal("test-client-id"))
Expect(users).To(HaveLen(2))
Expect(users["user1"]).To(Equal("token1"))
Expect(users["user2"]).To(Equal("token2"))
})
It("returns empty client ID when not set", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false)
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
clientID, users, err := getConfig()
Expect(err).ToNot(HaveOccurred())
Expect(clientID).To(BeEmpty())
Expect(users).To(BeNil())
})
It("returns nil users when users not configured", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{})
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
clientID, users, err := getConfig()
Expect(err).ToNot(HaveOccurred())
Expect(clientID).To(Equal("test-client-id"))
Expect(users).To(BeNil())
})
})
Describe("IsAuthorized", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns true for authorized user", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"})
host.ConfigMock.On("Get", "user.testuser").Return("token123", true)
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
Username: "testuser",
})
Expect(err).ToNot(HaveOccurred())
Expect(authorized).To(BeTrue())
})
It("returns false for unauthorized user", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"})
host.ConfigMock.On("Get", "user.otheruser").Return("token123", true)
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
Username: "testuser",
})
Expect(err).ToNot(HaveOccurred())
Expect(authorized).To(BeFalse())
})
})
Describe("NowPlaying", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("returns not authorized error when user not in config", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"})
host.ConfigMock.On("Get", "user.otheruser").Return("token", true)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
Username: "testuser",
Track: scrobbler.TrackInfo{Title: "Test Song"},
})
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue())
})
It("successfully sends now playing update", func() {
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"})
host.ConfigMock.On("Get", "user.testuser").Return("test-token", true)
// Connect mocks (isConnected check via heartbeat)
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
// Mock HTTP GET request for gateway discovery
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
gatewayReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
// Mock WebSocket connection
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
// Cancel existing clear schedule (may or may not exist)
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
// Image mocks - cache miss, will make HTTP request to Discord
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
// Mock HTTP request for Discord external assets API
assetsReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "external-assets")
})).Return(assetsReq)
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
// Schedule clear activity callback
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
Username: "testuser",
Position: 10,
Track: scrobbler.TrackInfo{
ID: "track1",
Title: "Test Song",
Artist: "Test Artist",
Album: "Test Album",
Duration: 180,
},
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("Scrobble", func() {
It("does nothing (returns nil)", func() {
err := plugin.Scrobble(scrobbler.ScrobbleRequest{})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("OnCallback", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
})
It("handles heartbeat callback", func() {
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
ScheduleID: "testuser",
Payload: payloadHeartbeat,
IsRecurring: true,
})
Expect(err).ToNot(HaveOccurred())
})
It("handles clearActivity callback", func() {
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
ScheduleID: "testuser-clear",
Payload: payloadClearActivity,
})
Expect(err).ToNot(HaveOccurred())
})
It("logs warning for unknown payload", func() {
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
ScheduleID: "testuser",
Payload: "unknown",
})
Expect(err).ToNot(HaveOccurred())
})
})
})

View File

@@ -1,102 +0,0 @@
{
"name": "Discord Rich Presence",
"author": "Navidrome Team",
"version": "1.0.0",
"description": "Discord Rich Presence integration for Navidrome",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
"permissions": {
"users": {
"reason": "To process scrobbles on behalf of users"
},
"http": {
"reason": "To communicate with Discord API for gateway discovery and image uploads",
"requiredHosts": [
"discord.com"
]
},
"websocket": {
"reason": "To maintain real-time connection with Discord gateway",
"requiredHosts": [
"gateway.discord.gg"
]
},
"cache": {
"reason": "To store connection state and sequence numbers"
},
"scheduler": {
"reason": "To schedule heartbeat messages and activity clearing"
},
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}

View File

@@ -1,400 +0,0 @@
// Discord Rich Presence Plugin - RPC Communication
//
// This file handles all Discord gateway communication including WebSocket connections,
// presence updates, and heartbeat management. The discordRPC struct implements WebSocket
// callback interfaces and encapsulates all Discord communication logic.
package main
import (
"encoding/json"
"fmt"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
)
// Discord WebSocket Gateway constants
const (
heartbeatOpCode = 1 // Heartbeat operation code
gateOpCode = 2 // Identify operation code
presenceOpCode = 3 // Presence update operation code
)
const (
heartbeatInterval = 41 // Heartbeat interval in seconds
defaultImage = "https://i.imgur.com/hb3XPzA.png"
)
// Scheduler callback payloads for routing
const (
payloadHeartbeat = "heartbeat"
payloadClearActivity = "clear-activity"
)
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
type discordRPC struct{}
// ============================================================================
// WebSocket Callback Implementation
// ============================================================================
// OnTextMessage handles incoming WebSocket text messages.
func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error {
return r.handleWebSocketMessage(input.ConnectionID, input.Message)
}
// OnBinaryMessage handles incoming WebSocket binary messages.
func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID))
return nil
}
// OnError handles WebSocket errors.
func (r *discordRPC) OnError(input websocket.OnErrorRequest) error {
pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error))
return nil
}
// OnClose handles WebSocket connection closure.
func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason))
return nil
}
// activity represents a Discord activity.
type activity struct {
Name string `json:"name"`
Type int `json:"type"`
Details string `json:"details"`
State string `json:"state"`
Application string `json:"application_id"`
Timestamps activityTimestamps `json:"timestamps"`
Assets activityAssets `json:"assets"`
}
type activityTimestamps struct {
Start int64 `json:"start"`
End int64 `json:"end"`
}
type activityAssets struct {
LargeImage string `json:"large_image"`
LargeText string `json:"large_text"`
}
// presencePayload represents a Discord presence update.
type presencePayload struct {
Activities []activity `json:"activities"`
Since int64 `json:"since"`
Status string `json:"status"`
Afk bool `json:"afk"`
}
// identifyPayload represents a Discord identify payload.
type identifyPayload struct {
Token string `json:"token"`
Intents int `json:"intents"`
Properties identifyProperties `json:"properties"`
}
type identifyProperties struct {
OS string `json:"os"`
Browser string `json:"browser"`
Device string `json:"device"`
}
// ============================================================================
// Image Processing
// ============================================================================
// processImage processes an image URL for Discord, with fallback to default image.
func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
if imageURL == "" {
if isDefaultImage {
return "", fmt.Errorf("default image URL is empty")
}
return r.processImage(defaultImage, clientID, token, true)
}
if strings.HasPrefix(imageURL, "mp:") {
return imageURL, nil
}
// Check cache first
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
cachedValue, exists, err := host.CacheGetString(cacheKey)
if err == nil && exists {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
return cachedValue, nil
}
// Process via Discord API
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
req.SetHeader("Authorization", token)
req.SetHeader("Content-Type", "application/json")
req.SetBody([]byte(body))
resp := req.Send()
if resp.Status() >= 400 {
if isDefaultImage {
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
}
return r.processImage(defaultImage, clientID, token, true)
}
var data []map[string]string
if err := json.Unmarshal(resp.Body(), &data); err != nil {
if isDefaultImage {
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
}
return r.processImage(defaultImage, clientID, token, true)
}
if len(data) == 0 {
if isDefaultImage {
return "", fmt.Errorf("no data returned for default image")
}
return r.processImage(defaultImage, clientID, token, true)
}
image := data[0]["external_asset_path"]
if image == "" {
if isDefaultImage {
return "", fmt.Errorf("empty external_asset_path for default image")
}
return r.processImage(defaultImage, clientID, token, true)
}
processedImage := fmt.Sprintf("mp:%s", image)
// Cache the processed image URL
var ttl int64 = 4 * 60 * 60 // 4 hours for regular images
if isDefaultImage {
ttl = 48 * 60 * 60 // 48 hours for default image
}
_ = host.CacheSetString(cacheKey, processedImage, ttl)
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl))
return processedImage, nil
}
// ============================================================================
// Activity Management
// ============================================================================
// sendActivity sends an activity update to Discord.
func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State))
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
if err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err))
data.Assets.LargeImage = ""
} else {
data.Assets.LargeImage = processedImage
}
presence := presencePayload{
Activities: []activity{data},
Status: "dnd",
Afk: false,
}
return r.sendMessage(username, presenceOpCode, presence)
}
// clearActivity clears the Discord activity for a user.
func (r *discordRPC) clearActivity(username string) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username))
return r.sendMessage(username, presenceOpCode, presencePayload{})
}
// ============================================================================
// Low-level Communication
// ============================================================================
// sendMessage sends a message over the WebSocket connection.
func (r *discordRPC) sendMessage(username string, opCode int, payload any) error {
message := map[string]any{
"op": opCode,
"d": payload,
}
b, err := json.Marshal(message)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
err = host.WebSocketSendText(username, string(b))
if err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
return nil
}
// getDiscordGateway retrieves the Discord gateway URL.
func (r *discordRPC) getDiscordGateway() (string, error) {
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
resp := req.Send()
if resp.Status() != 200 {
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status())
}
var result map[string]string
if err := json.Unmarshal(resp.Body(), &result); err != nil {
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
}
return result["url"], nil
}
// sendHeartbeat sends a heartbeat to Discord.
func (r *discordRPC) sendHeartbeat(username string) error {
seqNum, _, err := host.CacheGetInt(fmt.Sprintf("discord.seq.%s", username))
if err != nil {
return fmt.Errorf("failed to get sequence number: %w", err)
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending heartbeat for user %s: %d", username, seqNum))
return r.sendMessage(username, heartbeatOpCode, seqNum)
}
// cleanupFailedConnection cleans up a failed Discord connection.
func (r *discordRPC) cleanupFailedConnection(username string) {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username))
// Cancel the heartbeat schedule
if err := host.SchedulerCancelSchedule(username); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to cancel heartbeat schedule for user %s: %v", username, err))
}
// Close the WebSocket connection
if err := host.WebSocketCloseConnection(username, 1000, "Connection lost"); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to close WebSocket connection for user %s: %v", username, err))
}
// Clean up cache entries
_ = host.CacheRemove(fmt.Sprintf("discord.seq.%s", username))
pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaned up connection for user %s", username))
}
// isConnected checks if a user is connected to Discord by testing the heartbeat.
func (r *discordRPC) isConnected(username string) bool {
err := r.sendHeartbeat(username)
if err != nil {
pdk.Log(pdk.LogDebug, fmt.Sprintf("Heartbeat test failed for user %s: %v", username, err))
return false
}
return true
}
// connect establishes a connection to Discord for a user.
func (r *discordRPC) connect(username, token string) error {
if r.isConnected(username) {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Reusing existing connection for user %s", username))
return nil
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("Creating new connection for user %s", username))
// Get Discord Gateway URL
gateway, err := r.getDiscordGateway()
if err != nil {
return fmt.Errorf("failed to get Discord gateway: %w", err)
}
pdk.Log(pdk.LogDebug, fmt.Sprintf("Using gateway: %s", gateway))
// Connect to Discord Gateway
_, err = host.WebSocketConnect(gateway, nil, username)
if err != nil {
return fmt.Errorf("failed to connect to WebSocket: %w", err)
}
// Send identify payload
payload := identifyPayload{
Token: token,
Intents: 0,
Properties: identifyProperties{
OS: "Windows 10",
Browser: "Discord Client",
Device: "Discord Client",
},
}
if err := r.sendMessage(username, gateOpCode, payload); err != nil {
return fmt.Errorf("failed to send identify payload: %w", err)
}
// Schedule heartbeats for this user/connection
cronExpr := fmt.Sprintf("@every %ds", heartbeatInterval)
scheduleID, err := host.SchedulerScheduleRecurring(cronExpr, payloadHeartbeat, username)
if err != nil {
return fmt.Errorf("failed to schedule heartbeat: %w", err)
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduled heartbeat for user %s with ID %s", username, scheduleID))
pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username))
return nil
}
// disconnect closes the Discord connection for a user.
func (r *discordRPC) disconnect(username string) error {
if err := host.SchedulerCancelSchedule(username); err != nil {
return fmt.Errorf("failed to cancel schedule: %w", err)
}
if err := host.WebSocketCloseConnection(username, 1000, "Navidrome disconnect"); err != nil {
return fmt.Errorf("failed to close WebSocket connection: %w", err)
}
return nil
}
// handleWebSocketMessage processes incoming WebSocket messages from Discord.
func (r *discordRPC) handleWebSocketMessage(connectionID, message string) error {
if len(message) < 1024 {
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s': %s", connectionID, message))
} else {
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s' (truncated): %s...", connectionID, message[:1021]))
}
// Parse the message
var msg map[string]any
if err := json.Unmarshal([]byte(message), &msg); err != nil {
return fmt.Errorf("failed to parse WebSocket message: %w", err)
}
// Store sequence number if present
if v := msg["s"]; v != nil {
seq := int64(v.(float64))
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received sequence number for connection '%s': %d", connectionID, seq))
if err := host.CacheSetInt(fmt.Sprintf("discord.seq.%s", connectionID), seq, int64(heartbeatInterval*2)); err != nil {
return fmt.Errorf("failed to store sequence number for user %s: %w", connectionID, err)
}
}
return nil
}
// handleHeartbeatCallback processes heartbeat scheduler callbacks.
func (r *discordRPC) handleHeartbeatCallback(username string) error {
if err := r.sendHeartbeat(username); err != nil {
// On first heartbeat failure, immediately clean up the connection
pdk.Log(pdk.LogWarn, fmt.Sprintf("Heartbeat failed for user %s, cleaning up connection: %v", username, err))
r.cleanupFailedConnection(username)
return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
}
return nil
}
// handleClearActivityCallback processes clear activity scheduler callbacks.
func (r *discordRPC) handleClearActivityCallback(username string) error {
pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username))
if err := r.clearActivity(username); err != nil {
return fmt.Errorf("failed to clear activity: %w", err)
}
pdk.Log(pdk.LogInfo, fmt.Sprintf("Disconnecting user %s", username))
if err := r.disconnect(username); err != nil {
return fmt.Errorf("failed to disconnect from Discord: %w", err)
}
return nil
}

View File

@@ -1,279 +0,0 @@
package main
import (
"errors"
"strings"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("discordRPC", func() {
var r *discordRPC
BeforeEach(func() {
r = &discordRPC{}
pdk.ResetMock()
host.CacheMock.ExpectedCalls = nil
host.CacheMock.Calls = nil
host.WebSocketMock.ExpectedCalls = nil
host.WebSocketMock.Calls = nil
host.SchedulerMock.ExpectedCalls = nil
host.SchedulerMock.Calls = nil
})
Describe("sendMessage", func() {
It("sends JSON message over WebSocket", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`)
})).Return(nil)
err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"})
Expect(err).ToNot(HaveOccurred())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
It("returns error when WebSocket send fails", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", mock.Anything, mock.Anything).
Return(errors.New("connection closed"))
err := r.sendMessage("testuser", presenceOpCode, map[string]string{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("connection closed"))
})
})
Describe("sendHeartbeat", func() {
It("retrieves sequence number from cache and sends heartbeat", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123")
})).Return(nil)
err := r.sendHeartbeat("testuser")
Expect(err).ToNot(HaveOccurred())
host.CacheMock.AssertExpectations(GinkgoT())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
It("returns error when cache get fails", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error"))
err := r.sendHeartbeat("testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("cache error"))
})
})
Describe("connect", func() {
It("establishes WebSocket connection and sends identify payload", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
// Mock HTTP GET request for gateway discovery
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
// Mock WebSocket connection
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
return strings.Contains(url, "gateway.discord.gg")
}), mock.Anything, "testuser").Return("testuser", nil)
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token")
})).Return(nil)
host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser").
Return("testuser", nil)
err := r.connect("testuser", "test-token")
Expect(err).ToNot(HaveOccurred())
})
It("reuses existing connection if connected", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
err := r.connect("testuser", "test-token")
Expect(err).ToNot(HaveOccurred())
host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything)
})
})
Describe("disconnect", func() {
It("cancels schedule and closes WebSocket connection", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
err := r.disconnect("testuser")
Expect(err).ToNot(HaveOccurred())
host.SchedulerMock.AssertExpectations(GinkgoT())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
})
Describe("cleanupFailedConnection", func() {
It("cancels schedule, closes WebSocket, and clears cache", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
r.cleanupFailedConnection("testuser")
host.SchedulerMock.AssertExpectations(GinkgoT())
host.WebSocketMock.AssertExpectations(GinkgoT())
})
})
Describe("handleHeartbeatCallback", func() {
It("sends heartbeat successfully", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
err := r.handleHeartbeatCallback("testuser")
Expect(err).ToNot(HaveOccurred())
})
It("cleans up connection on heartbeat failure", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss"))
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
err := r.handleHeartbeatCallback("testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("connection cleaned up"))
})
})
Describe("handleClearActivityCallback", func() {
It("clears activity and disconnects", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
})).Return(nil)
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
err := r.handleClearActivityCallback("testuser")
Expect(err).ToNot(HaveOccurred())
})
})
Describe("WebSocket callbacks", func() {
Describe("OnTextMessage", func() {
It("handles valid JSON message", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil)
err := r.OnTextMessage(websocket.OnTextMessageRequest{
ConnectionID: "testuser",
Message: `{"s":42}`,
})
Expect(err).ToNot(HaveOccurred())
})
It("returns error for invalid JSON", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnTextMessage(websocket.OnTextMessageRequest{
ConnectionID: "testuser",
Message: `not json`,
})
Expect(err).To(HaveOccurred())
})
})
Describe("OnBinaryMessage", func() {
It("handles binary message without error", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{
ConnectionID: "testuser",
Data: "AQID", // base64 encoded [0x01, 0x02, 0x03]
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("OnError", func() {
It("handles error without returning error", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnError(websocket.OnErrorRequest{
ConnectionID: "testuser",
Error: "test error",
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("OnClose", func() {
It("handles close without returning error", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
err := r.OnClose(websocket.OnCloseRequest{
ConnectionID: "testuser",
Code: 1000,
Reason: "normal close",
})
Expect(err).ToNot(HaveOccurred())
})
})
})
Describe("sendActivity", func() {
BeforeEach(func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
return strings.HasPrefix(key, "discord.image.")
})).Return("", false, nil)
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
// Mock HTTP request for Discord external assets API (image processing)
// When processImage is called, it makes an HTTP request
httpReq := &pdk.HTTPRequest{}
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq)
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
})
It("sends activity update to Discord", func() {
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) &&
strings.Contains(msg, `"name":"Test Song"`) &&
strings.Contains(msg, `"state":"Test Artist"`)
})).Return(nil)
err := r.sendActivity("client123", "testuser", "token123", activity{
Application: "client123",
Name: "Test Song",
Type: 2,
State: "Test Artist",
Details: "Test Album",
})
Expect(err).ToNot(HaveOccurred())
})
})
Describe("clearActivity", func() {
It("sends presence update with nil activities", func() {
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
})).Return(nil)
err := r.clearActivity("testuser")
Expect(err).ToNot(HaveOccurred())
})
})
})

View File

@@ -15,4 +15,10 @@ type SubsonicAPIService interface {
// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
//nd:hostfunc
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
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
}

View File

@@ -4,6 +4,7 @@ package host
import (
"context"
"encoding/binary"
"encoding/json"
extism "github.com/extism/go-sdk"
@@ -20,11 +21,17 @@ type SubsonicAPICallResponse struct {
Error string `json:"error,omitempty"`
}
// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw.
type SubsonicAPICallRawRequest struct {
Uri string `json:"uri"`
}
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
return []extism.HostFunction{
newSubsonicAPICallHostFunction(service),
newSubsonicAPICallRawHostFunction(service),
}
}
@@ -62,6 +69,50 @@ func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunct
)
}
func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"subsonicapi_callraw",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Read JSON request from plugin memory
reqBytes, err := p.ReadBytes(stack[0])
if err != nil {
subsonicapiWriteRawError(p, stack, err)
return
}
var req SubsonicAPICallRawRequest
if err := json.Unmarshal(reqBytes, &req); err != nil {
subsonicapiWriteRawError(p, stack, err)
return
}
// Call the service method
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
if svcErr != nil {
subsonicapiWriteRawError(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
}
stack[0] = respPtr
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// subsonicapiWriteResponse writes a JSON response to plugin memory.
func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
@@ -86,3 +137,14 @@ 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
}

View File

@@ -24,7 +24,7 @@ const subsonicAPIVersion = "1.16.1"
//
// Authentication: The plugin must provide a valid 'u' (username) parameter in the URL.
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format).
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
type subsonicAPIServiceImpl struct {
pluginID string
router SubsonicRouter
@@ -50,15 +50,18 @@ func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.Data
}
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
// executeRequest handles URL parsing, validation, permission checks, HTTP request creation,
// and router invocation. Shared between Call and CallRaw.
// If setJSON is true, the 'f=json' query parameter is added.
func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) {
if s.router == nil {
return "", fmt.Errorf("SubsonicAPI router not available")
return nil, fmt.Errorf("SubsonicAPI router not available")
}
// Parse the input URL
parsedURL, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("invalid URL format: %w", err)
return nil, fmt.Errorf("invalid URL format: %w", err)
}
// Extract query parameters
@@ -67,18 +70,20 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
// Validate that 'u' (username) parameter is present
username := query.Get("u")
if username == "" {
return "", fmt.Errorf("missing required parameter 'u' (username)")
return nil, fmt.Errorf("missing required parameter 'u' (username)")
}
if err := s.checkPermissions(ctx, username); err != nil {
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
return "", err
return nil, err
}
// Add required Subsonic API parameters
query.Set("c", s.pluginID) // Client name (plugin ID)
query.Set("f", "json") // Response format
query.Set("v", subsonicAPIVersion) // API version
if setJSON {
query.Set("f", "json") // Response format
}
// Extract the endpoint from the path
endpoint := path.Base(parsedURL.Path)
@@ -96,7 +101,7 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
// explicitly added in the next step via request.WithInternalAuth.
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return "", fmt.Errorf("failed to create HTTP request: %w", err)
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set internal authentication context using the username from the 'u' parameter
@@ -109,10 +114,26 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
// Call the subsonic router
s.router.ServeHTTP(recorder, httpReq)
// Return the response body as JSON
return recorder, nil
}
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
recorder, err := s.executeRequest(ctx, uri, true)
if err != nil {
return "", err
}
return recorder.Body.String(), nil
}
func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) {
recorder, err := s.executeRequest(ctx, uri, false)
if err != nil {
return "", nil, err
}
contentType := recorder.Header().Get("Content-Type")
return contentType, recorder.Body.Bytes(), nil
}
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
// If allUsers is true, allow any user
if s.allUsers {

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"github.com/navidrome/navidrome/conf"
@@ -177,6 +178,61 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
})
Describe("SubsonicAPI CallRaw", func() {
var plugin *plugin
BeforeEach(func() {
manager.mu.RLock()
plugin = manager.plugins["test-subsonicapi-plugin"]
manager.mu.RUnlock()
Expect(plugin).ToNot(BeNil())
})
It("successfully calls getCoverArt and returns binary data", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
Expect(err).ToNot(HaveOccurred())
Expect(exit).To(Equal(uint32(0)))
// Parse the metadata response from the test plugin
var result map[string]any
err = json.Unmarshal(output, &result)
Expect(err).ToNot(HaveOccurred())
Expect(result["contentType"]).To(Equal("image/png"))
Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader)))
Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte
})
It("does NOT set f=json parameter for raw calls", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
_, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
Expect(err).ToNot(HaveOccurred())
Expect(router.lastRequest).ToNot(BeNil())
query := router.lastRequest.URL.Query()
Expect(query.Get("f")).To(BeEmpty())
Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin"))
Expect(query.Get("v")).To(Equal("1.16.1"))
})
It("returns error when username is missing", func() {
instance, err := plugin.instance(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
defer instance.Close(GinkgoT().Context())
exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt"))
Expect(err).To(HaveOccurred())
Expect(exit).To(Equal(uint32(1)))
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
})
})
var _ = Describe("SubsonicAPIService", func() {
@@ -323,6 +379,66 @@ var _ = Describe("SubsonicAPIService", func() {
})
})
Describe("CallRaw", func() {
It("returns binary data and content-type", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
Expect(err).ToNot(HaveOccurred())
Expect(contentType).To(Equal("image/png"))
Expect(data).To(Equal(fakePNGHeader))
})
It("does not set f=json parameter", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
Expect(err).ToNot(HaveOccurred())
Expect(router.lastRequest).ToNot(BeNil())
query := router.lastRequest.URL.Query()
Expect(query.Get("f")).To(BeEmpty())
})
It("enforces permission checks", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
It("returns error when username is missing", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
})
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("router not available"))
})
It("returns error for invalid URL", func() {
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
ctx := GinkgoT().Context()
_, _, err := service.CallRaw(ctx, "://invalid")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid URL"))
})
})
Describe("Router Availability", func() {
It("returns error when router is nil", func() {
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
@@ -335,6 +451,9 @@ var _ = Describe("SubsonicAPIService", func() {
})
})
// fakePNGHeader is a minimal PNG file header used in tests.
var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses.
type fakeSubsonicRouter struct {
lastRequest *http.Request
@@ -343,13 +462,20 @@ type fakeSubsonicRouter struct {
func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.lastRequest = req
// Return a successful ping response
response := map[string]any{
"subsonic-response": map[string]any{
"status": "ok",
"version": "1.16.1",
},
endpoint := path.Base(req.URL.Path)
switch endpoint {
case "getCoverArt":
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(fakePNGHeader)
default:
// Return a successful ping response
response := map[string]any{
"subsonic-response": map[string]any{
"status": "ok",
"version": "1.16.1",
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}

View File

@@ -73,7 +73,8 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
if exit != 0 {
if exit == notImplementedCode {
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
// TODO Should we record metrics for not implemented calls?
//plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
}
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())

View File

@@ -106,7 +106,7 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() {
Expect(calls[0].ok).To(BeFalse())
})
It("records metrics for not-implemented functions", func() {
It("does not record metrics for not-implemented functions", func() {
// Use partial metadata agent that doesn't implement GetArtistMBID
partialRecorder := &mockMetricsRecorder{}
partialManager, _ := createTestManagerWithPluginsAndMetrics(
@@ -123,9 +123,6 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() {
Expect(err).To(MatchError(errNotImplemented))
calls := partialRecorder.getCalls()
Expect(calls).To(HaveLen(1))
Expect(calls[0].plugin).To(Equal("partial-metadata-agent"))
Expect(calls[0].method).To(Equal(FuncGetArtistMBID))
Expect(calls[0].ok).To(BeTrue())
Expect(calls).To(HaveLen(0))
})
})

View File

@@ -6,10 +6,3 @@ 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
)

View File

@@ -8,6 +8,7 @@
package host
import (
"encoding/binary"
"encoding/json"
"errors"
@@ -19,6 +20,11 @@ import (
//go:wasmimport extism:host/user subsonicapi_call
func subsonicapi_call(uint64) uint64
// subsonicapi_callraw is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user subsonicapi_callraw
func subsonicapi_callraw(uint64) uint64
type subsonicAPICallRequest struct {
Uri string `json:"uri"`
}
@@ -28,6 +34,10 @@ type subsonicAPICallResponse struct {
Error string `json:"error,omitempty"`
}
type subsonicAPICallRawRequest struct {
Uri string `json:"uri"`
}
// SubsonicAPICall calls the subsonicapi_call host function.
// Call executes a Subsonic API request and returns the JSON response.
//
@@ -65,3 +75,46 @@ func SubsonicAPICall(uri string) (string, error) {
return response.ResponseJSON, nil
}
// 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.
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
// Marshal request to JSON
req := subsonicAPICallRawRequest{
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 := subsonicapi_callraw(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
}

View File

@@ -33,3 +33,17 @@ func (m *mockSubsonicAPIService) Call(uri string) (string, error) {
func SubsonicAPICall(uri string) (string, error) {
return SubsonicAPIMock.Call(uri)
}
// CallRaw is the mock method for SubsonicAPICallRaw.
func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
args := m.Called(uri)
return args.String(0), args.Get(1).([]byte), args.Error(2)
}
// 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.
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
return SubsonicAPIMock.CallRaw(uri)
}

View File

@@ -8,10 +8,11 @@
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
from typing import Any, Tuple
import extism
import json
import struct
class HostFunctionError(Exception):
@@ -25,6 +26,12 @@ def _subsonicapi_call(offset: int) -> int:
...
@extism.import_fn("extism:host/user", "subsonicapi_callraw")
def _subsonicapi_callraw(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def subsonicapi_call(uri: str) -> str:
"""Call executes a Subsonic API request and returns the JSON response.
@@ -53,3 +60,42 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
raise HostFunctionError(response["error"])
return response.get("responseJson", "")
def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]:
"""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.
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 = _subsonicapi_callraw(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

View File

@@ -28,9 +28,9 @@ checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "bytes"
version = "1.11.0"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "either"
@@ -171,7 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "nd-host"
name = "nd-pdk-host"
version = "0.1.0"
dependencies = [
"extism-pdk",

View File

@@ -21,11 +21,22 @@ struct SubsonicAPICallResponse {
error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SubsonicAPICallRawRequest {
uri: String,
}
#[host_fn]
extern "ExtismHost" {
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
}
#[link(wasm_import_module = "extism:host/user")]
extern "C" {
fn subsonicapi_callraw(offset: u64) -> u64;
}
/// Call executes a Subsonic API request and returns the JSON response.
///
/// The uri parameter should be the Subsonic API path without the server prefix,
@@ -52,3 +63,56 @@ pub fn call(uri: &str) -> Result<String, Error> {
Ok(response.0.response_json)
}
/// 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.
///
/// # 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 call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> {
let req = SubsonicAPICallRawRequest {
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 { subsonicapi_callraw(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))
}

View File

@@ -3,6 +3,8 @@
package main
import (
"fmt"
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
@@ -28,4 +30,28 @@ func callSubsonicAPIExport() int32 {
return 0
}
// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function.
// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1")
// Output: JSON with contentType, size, and first bytes of the raw response
//
//go:wasmexport call_subsonic_api_raw
func callSubsonicAPIRawExport() int32 {
uri := pdk.InputString()
contentType, data, err := host.SubsonicAPICallRaw(uri)
if err != nil {
pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error())
return 1
}
// Return metadata about the raw response as JSON
firstByte := 0
if len(data) > 0 {
firstByte = int(data[0])
}
result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte)
pdk.OutputString(result)
return 0
}
func main() {}

View File

@@ -95,11 +95,11 @@
"lists": {
"all": "Tot",
"random": "Aleatori",
"recentlyAdded": "Afegit fa poc",
"recentlyPlayed": "Reproduït fa poc",
"mostPlayed": "Més reproduït",
"recentlyAdded": "Afegits recentment",
"recentlyPlayed": "Reproduïts recentment",
"mostPlayed": "Més reproduïts",
"starred": "Preferits",
"topRated": "Més ben valorades"
"topRated": "Més ben valorats"
}
},
"artist": {
@@ -119,7 +119,7 @@
"albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
"artist": "Artista |||| Artistes",
"composer": "Compositor |||| Compositors",
"conductor": "Conductor |||| Conductors",
"conductor": "Director |||| Directors",
"lyricist": "Lletrista |||| Lletristes",
"arranger": "Arranjador |||| Arranjadors",
"producer": "Productor |||| Productors",
@@ -127,7 +127,7 @@
"engineer": "Enginyer |||| Enginyers",
"mixer": "Mesclador |||| Mescladors",
"remixer": "Remesclador |||| Remescladors",
"djmixer": "DJ Mesclador |||| DJ Mescladors",
"djmixer": "Mesclador DJ |||| Mescladors DJ",
"performer": "Intèrpret |||| Intèrprets",
"maincredit": "Artista de l'àlbum or Artista |||| Artistes de l'àlbum or Artistes"
},
@@ -654,7 +654,7 @@
"links": {
"homepage": "Inici",
"source": "Codi font",
"featureRequests": "Sol·licitud de funcionalitats",
"featureRequests": "Sol·licita funcionalitats",
"lastInsightsCollection": "Última recolecció d'informació",
"insights": {
"disabled": "Desactivada",

View File

@@ -48,7 +48,7 @@
"playNext": "Als nächstes abspielen",
"info": "Mehr Informationen",
"showInPlaylist": "In Wiedergabeliste anzeigen",
"instantMix": ""
"instantMix": "Sofort-Mix"
}
},
"album": {
@@ -588,7 +588,7 @@
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
"noTopSongsFound": "Keine beliebten Titel gefunden",
"startingInstantMix": ""
"startingInstantMix": "Lade Sofort-Mix..."
},
"menu": {
"library": "Bibliothek",

View File

@@ -48,7 +48,7 @@
"playNext": "Siguiente",
"info": "Obtener información",
"showInPlaylist": "Mostrar en la lista de reproducción",
"instantMix": ""
"instantMix": "Mezcla instantánea"
}
},
"album": {
@@ -588,7 +588,7 @@
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares",
"noTopSongsFound": "No se encontraron canciones destacadas",
"startingInstantMix": ""
"startingInstantMix": "Cargando la mezcla instantánea..."
},
"menu": {
"library": "Biblioteca",

View File

@@ -48,7 +48,7 @@
"playNext": "Jouer ensuite",
"info": "Plus d'informations",
"showInPlaylist": "Montrer dans la playlist",
"instantMix": ""
"instantMix": "Mix instantanné"
}
},
"album": {
@@ -588,7 +588,7 @@
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.",
"noSimilarSongsFound": "Aucun titre similaire n'a été trouvé",
"noTopSongsFound": "Aucun meilleur titre n'a été trouvé",
"startingInstantMix": ""
"startingInstantMix": "Chargement du mix instantanné..."
},
"menu": {
"library": "Bibliothèque",

View File

@@ -36,7 +36,8 @@
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
"missing": "Hilang",
"libraryName": "Pustaka"
"libraryName": "Pustaka",
"composer": "Komposer"
},
"actions": {
"addToQueue": "Tambah ke antrean",
@@ -46,7 +47,8 @@
"download": "Unduh",
"playNext": "Putar Berikutnya",
"info": "Lihat Info",
"showInPlaylist": "Tampilkan di Playlist"
"showInPlaylist": "Tampilkan di Playlist",
"instantMix": "Mix Instan"
}
},
"album": {
@@ -301,14 +303,19 @@
"actions": {
"scan": "Pindai Pustaka",
"manageUsers": "Kelola Akses Pengguna",
"viewDetails": "Lihat Detail"
"viewDetails": "Lihat Detail",
"quickScan": "Pindai Cepat",
"fullScan": "Pindai Keseluruhan"
},
"notifications": {
"created": "Pustaka berhasil dibuat",
"updated": "Pustaka berhasil dibuat",
"deleted": "Berhasil menghapus pustaka",
"scanStarted": "Memindai pustaka dimulai",
"scanCompleted": "Memindai pustaka selesai"
"scanCompleted": "Memindai pustaka selesai",
"quickScanStarted": "Pemindaian cepat dimulai",
"fullScanStarted": "Pemindaian keseluruhan dimulai",
"scanError": "Kesalahan saat memulai pemindaian. Periksa log"
},
"validation": {
"nameRequired": "Nama pustaka diperlukan",
@@ -323,6 +330,80 @@
"scanInProgress": "Pemindaian sedang berlangsung...",
"noLibrariesAssigned": "Tidak ada pustaka yang ditugaskan ke pengguna ini"
}
},
"plugin": {
"name": "Plugin |||| Plugin",
"fields": {
"id": "ID",
"name": "Nama",
"description": "Deskripsi",
"version": "Versi",
"author": "Pembuat",
"website": "Situs Web",
"permissions": "Perizinan",
"enabled": "Diaktifkan",
"status": "Status",
"path": "Jalur",
"lastError": "Kesalahan",
"hasError": "Kesalahan",
"updatedAt": "Diperbarui",
"createdAt": "Terinstal",
"configKey": "Key",
"configValue": "Value",
"allUsers": "Izinkan semua pengguna",
"selectedUsers": "Pengguna yang dipilih",
"allLibraries": "Izinkan semua pustaka",
"selectedLibraries": "Pustaka dipilih"
},
"sections": {
"status": "Status",
"info": "Informasi plugin",
"configuration": "Konfigurasi",
"manifest": "Manifes",
"usersPermission": "Pengguna yang Diizinkan",
"libraryPermission": "Pustaka yang Diizinkan"
},
"status": {
"enabled": "Diaktifkan",
"disabled": "Dinonaktifkan"
},
"actions": {
"enable": "Aktifkan",
"disable": "Nonaktifkan",
"disabledDueToError": "Perbaiki kesalahan sebelum diaktifkan",
"disabledUsersRequired": "Pilih pengguna sebelum diaktifkan",
"disabledLibrariesRequired": "Pilih pustaka sebelum diaktifkan",
"addConfig": "Tambahkan Konfigurasi",
"rescan": "Pindai ulang"
},
"notifications": {
"enabled": "Plugin diaktifkan",
"disabled": "Plugin dinonaktifkan",
"updated": "Plugin diperbarui",
"error": "Kesalahan saat memperbarui plugin"
},
"validation": {
"invalidJson": "Konfigurasi harus berupa JSON yang valid"
},
"messages": {
"configHelp": "Konfigurasikan plugin menggunakan key-value pairs. Biarkan kosong jika plugin tidak membutuhkan konfigurasi.",
"clickPermissions": "Klik perizinan untuk detail",
"noConfig": "Konfigurasi tidak diatur",
"allUsersHelp": "Ketika diaktifkan, plugin akan mengakses untuk semua pengguna, termasuk yang akan dibuat di masa depan.",
"noUsers": "Tidak ada pengguna yang dipilih",
"permissionReason": "Alasan",
"usersRequired": "Plugin ini membutuhkan akses ke informasi pengguna. Pilih pengguna yang bisa mengakses plugin, atau aktifkan 'Izinkan semua pengguna'.",
"allLibrariesHelp": "Ketika diaktifkan, plugin akan memiliki akses ke semua pustaka, termasuk yang dibuat di masa depan.",
"noLibraries": "Tidak ada pustaka yang dipilih",
"librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.",
"requiredHosts": "Hosts diperlukan",
"configValidationError": "Validasi konfigurasi gagal:",
"schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid."
},
"placeholders": {
"configKey": "key",
"configValue": "value"
}
}
},
"ra": {
@@ -506,7 +587,8 @@
"remove_all_missing_title": "Hapus semua file yang hilang",
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.",
"noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan",
"noTopSongsFound": "Tidak ada lagu teratas ditemukan"
"noTopSongsFound": "Tidak ada lagu teratas ditemukan",
"startingInstantMix": "Memuat Mix Instan..."
},
"menu": {
"library": "Pustaka",
@@ -604,7 +686,8 @@
"serverDown": "LURING",
"scanType": "Tipe",
"status": "Kesalahan Memindai",
"elapsedTime": "Waktu Berakhir"
"elapsedTime": "Waktu Berakhir",
"selectiveScan": "Selektif"
},
"help": {
"title": "Tombol Pintasan Navidrome",

View File

@@ -48,7 +48,7 @@
"playNext": "Odtwarzaj Następny",
"info": "Zdobądź Informacje",
"showInPlaylist": "Pokaż w Liście Odtwarzania",
"instantMix": ""
"instantMix": "Natychmiastowy Miks"
}
},
"album": {
@@ -588,7 +588,7 @@
"remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.",
"noSimilarSongsFound": "Brak podobnych utworów",
"noTopSongsFound": "Brak najlepszych utworów",
"startingInstantMix": ""
"startingInstantMix": "Ładowanie Natychmiastowego Miksu..."
},
"menu": {
"library": "Biblioteka",

30
ui/package-lock.json generated
View File

@@ -18,6 +18,7 @@
"clsx": "^2.1.1",
"connected-react-router": "^6.9.3",
"deepmerge": "^4.3.1",
"dompurify": "^3.3.1",
"history": "^4.10.1",
"inflection": "^3.0.2",
"jwt-decode": "^4.0.0",
@@ -2394,9 +2395,9 @@
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
@@ -5502,10 +5503,13 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
"license": "(MPL-2.0 OR Apache-2.0)"
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dot-prop": {
"version": "9.0.0",
@@ -8379,9 +8383,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
@@ -9677,6 +9681,12 @@
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==",
"license": "MIT"
},
"node_modules/ra-ui-materialui/node_modules/dompurify": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
"license": "(MPL-2.0 OR Apache-2.0)"
},
"node_modules/ra-ui-materialui/node_modules/inflection": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",

View File

@@ -27,6 +27,7 @@
"clsx": "^2.1.1",
"connected-react-router": "^6.9.3",
"deepmerge": "^4.3.1",
"dompurify": "^3.3.1",
"history": "^4.10.1",
"inflection": "^3.0.2",
"jwt-decode": "^4.0.0",

View File

@@ -33,6 +33,7 @@ import {
import config from '../config'
import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
@@ -225,8 +226,7 @@ const AlbumDetails = (props) => {
const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false)
let notes =
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
let notes = albumInfo?.notes || record.notes
if (notes) {
notes += '..'
@@ -351,7 +351,9 @@ const AlbumDetails = (props) => {
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: notes }} />
<span>
<SafeHTML>{notes}</SafeHTML>
</span>
</Typography>
</Collapse>
)}
@@ -371,7 +373,9 @@ const AlbumDetails = (props) => {
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: notes }} />
<span>
<SafeHTML>{notes}</SafeHTML>
</span>
</Typography>
</Collapse>
</div>

View File

@@ -4,7 +4,7 @@ import { IconButton, Tooltip, Link } from '@material-ui/core'
import { ImLastfm2 } from 'react-icons/im'
import MusicBrainz from '../icons/MusicBrainz'
import { intersperse } from '../utils'
import { intersperse, isLastFmURL } from '../utils'
import config from '../config'
import { makeStyles } from '@material-ui/core/styles'
@@ -38,13 +38,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
}
if (config.lastFMEnabled) {
if (lastFMlink) {
if (lastFMlink && isLastFmURL(lastFMlink[2])) {
addLink(
lastFMlink[2],
'message.openIn.lastfm',
<ImLastfm2 className="lastfm-icon" />,
)
} else if (artistInfo?.lastFmUrl) {
} else if (isLastFmURL(artistInfo?.lastFmUrl)) {
addLink(
artistInfo?.lastFmUrl,
'message.openIn.lastfm',

View File

@@ -1,4 +1,4 @@
import React, { useState, createElement, useEffect } from 'react'
import { useState, useEffect } from 'react'
import { useMediaQuery, withWidth } from '@material-ui/core'
import {
useShowController,
@@ -53,9 +53,7 @@ const ArtistDetails = (props) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const [artistInfo, setArtistInfo] = useState()
const biography =
artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '') ||
record.biography
const biography = artistInfo?.biography || record.biography
useEffect(() => {
subsonic
@@ -72,15 +70,9 @@ const ArtistDetails = (props) => {
})
}, [record.id])
const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
return (
<>
{createElement(component, {
artistInfo,
record,
biography,
})}
</>
<Component artistInfo={artistInfo} record={record} biography={biography} />
)
}

View File

@@ -11,6 +11,7 @@ import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
@@ -172,7 +173,9 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span dangerouslySetInnerHTML={{ __html: biography }} />
<span>
<SafeHTML>{biography}</SafeHTML>
</span>
</Typography>
</Collapse>
</CardContent>

View File

@@ -7,6 +7,7 @@ import config from '../config'
import { LoveButton, RatingField } from '../common'
import Lightbox from 'react-image-lightbox'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
@@ -168,7 +169,9 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<div className={classes.biography}>
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
<Typography variant={'body1'} onClick={() => setExpanded(!expanded)}>
<span dangerouslySetInnerHTML={{ __html: biography }} />
<span>
<SafeHTML>{biography}</SafeHTML>
</span>
</Typography>
</Collapse>
</div>

View File

@@ -53,12 +53,7 @@ const Linkify = ({ text, ...rest }) => {
// Push remaining text
if (text.length > lastIndex) {
elements.push(
<span
key={'last-span-key'}
dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }}
/>,
)
elements.push(text.substring(lastIndex))
}
return elements.length === 1 ? elements[0] : elements

View File

@@ -28,19 +28,7 @@ export const MultiLineTextField = memo(
component="span"
{...sanitizeFieldRestProps(rest)}
>
{lines.length === 0 && emptyText
? emptyText
: lines.map((line, idx) =>
line === '' ? (
<br key={md5(line + idx)} />
) : (
<div
data-testid={`${source}.${idx}`}
key={md5(line + idx)}
dangerouslySetInnerHTML={{ __html: line }}
/>
),
)}
{lines.length === 0 && emptyText ? emptyText : lines}
</Typography>
)
},

View File

@@ -1,28 +0,0 @@
import * as React from 'react'
import { render, cleanup, screen } from '@testing-library/react'
import { MultiLineTextField } from './MultiLineTextField'
describe('<MultiLineTextField />', () => {
afterEach(cleanup)
it('should render each line in a separated div', () => {
const record = { comment: 'line1\nline2' }
render(<MultiLineTextField record={record} source={'comment'} />)
expect(screen.queryByTestId('comment.0').textContent).toBe('line1')
expect(screen.queryByTestId('comment.1').textContent).toBe('line2')
})
it.each([null, undefined])(
'should render the emptyText when value is %s',
(body) => {
render(
<MultiLineTextField
record={{ id: 123, body }}
emptyText="NA"
source="body"
/>,
)
expect(screen.getByText('NA')).toBeInTheDocument()
},
)
})

View File

@@ -0,0 +1,27 @@
import DOMPurify from 'dompurify'
import { useMemo } from 'react'
export const SafeHTML = ({ children }) => {
const purified = useMemo(() => {
const purify = DOMPurify()
purify.addHook('afterSanitizeElements', async (node) => {
if (node instanceof HTMLElement) {
// Set referrer-policy for elements with src
switch (node.tagName.toLowerCase()) {
case 'a':
case 'area':
case 'img':
case 'video':
case 'iframe':
case 'script':
node.setAttribute('referrer-policy', 'no-referrer')
}
}
})
return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] })
}, [children])
return <span dangerouslySetInnerHTML={{ __html: purified }} />
}

View File

@@ -136,6 +136,8 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
{config.welcomeMessage && (
<div
className={classes.welcome}
// Use dangerouslySetInnerHTML to allow admins to configure
// whatever content they want
dangerouslySetInnerHTML={{ __html: config.welcomeMessage }}
/>
)}

View File

@@ -8,6 +8,7 @@
// ============================================
const ACCENT_COLOR = '#009688' // Material teal
const UNBOUNDED_FONT_PATH = 'fonts/Unbounded-Variable.woff2'
// ============================================
// DESIGN TOKENS
@@ -69,7 +70,7 @@ const tokens = {
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/Unbounded-Variable.woff2') format('woff2');
src: url('${UNBOUNDED_FONT_PATH}') format('woff2');
}
`,
},
@@ -275,7 +276,7 @@ const NautilineTheme = {
fontStyle: 'normal',
fontWeight: '300 800',
fontDisplay: 'swap',
src: "url('/fonts/Unbounded-Variable.woff2') format('woff2')",
src: `url('${UNBOUNDED_FONT_PATH}') format('woff2')`,
},
body: {
backgroundColor: colors.background.primary,
@@ -794,7 +795,7 @@ const NautilineTheme = {
font-style: normal;
font-weight: 300 800;
font-display: swap;
src: url('/fonts/Unbounded-Variable.woff2') format('woff2');
src: url('${UNBOUNDED_FONT_PATH}') format('woff2');
}
.react-jinke-music-player-main {

View File

@@ -44,3 +44,16 @@ export const shareCoverUrl = (id, square) => {
}
export const docsUrl = (path) => `https://www.navidrome.org${path}`
export const isLastFmURL = (url) => {
try {
const parsed = new URL(url)
return (
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
(parsed.hostname === 'last.fm' || parsed.hostname.endsWith('.last.fm')) &&
parsed.pathname.startsWith('/music/')
)
} catch (e) {
return false
}
}

25
ui/src/utils/urls.test.js Normal file
View File

@@ -0,0 +1,25 @@
import { isLastFmURL } from './urls'
describe('isLastFmURL', () => {
it('returns true for valid Last.fm music URLs', () => {
expect(isLastFmURL('https://last.fm/music/The+Beatles')).toBe(true)
expect(isLastFmURL('http://last.fm/music/Radiohead')).toBe(true)
expect(isLastFmURL('https://www.last.fm/music/Daft+Punk')).toBe(true)
})
it('returns false for non-http(s) protocols (XSS prevention)', () => {
expect(isLastFmURL('javascript:alert(1)//last.fm/music/')).toBe(false)
expect(isLastFmURL('data:text/html,<script>//last.fm/music/')).toBe(false)
})
it('returns false for non-last.fm domains', () => {
expect(isLastFmURL('https://example.com/?q=last.fm/music/')).toBe(false)
expect(isLastFmURL('https://fake-last.fm/music/Artist')).toBe(false)
})
it('returns false for invalid paths or inputs', () => {
expect(isLastFmURL('https://last.fm/user/someone')).toBe(false)
expect(isLastFmURL(null)).toBe(false)
expect(isLastFmURL('not-a-url')).toBe(false)
})
})