diff --git a/plugins/cmd/hostgen/README.md b/plugins/cmd/hostgen/README.md
index 240deda5d..0a59c5325 100644
--- a/plugins/cmd/hostgen/README.md
+++ b/plugins/cmd/hostgen/README.md
@@ -19,8 +19,12 @@ hostgen -input
-output -package [-v] [-dry-run] [-host-only]
| `-dry-run` | Parse and validate without writing files | `false` |
| `-host-only` | Generate only host-side wrapper code | `false` |
| `-plugin-only` | Generate only plugin/client-side wrapper code | `false` |
+| `-go` | Generate Go client wrappers | `true`* |
+| `-python` | Generate Python client wrappers | `false` |
-By default, both host and plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type.
+\* `-go` is enabled by default when `-python` is not specified. Use both `-go -python` to generate both.
+
+By default, both host and Go plugin code are generated. Use `-host-only` or `-plugin-only` to generate only one type. Use `-python` to generate Python wrappers.
### Example
@@ -192,8 +196,68 @@ Generated files are named `nd_host_.go` (lowercase) and placed in t
```
output/
├── subsonicapi_gen.go # Host-side code (for Navidrome)
-└── go/
- └── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins)
+├── go/
+│ └── nd_host_subsonicapi.go # Plugin-side code (for TinyGo plugins)
+└── python/
+ └── nd_host_subsonicapi.py # Plugin-side code (for Python plugins)
+```
+
+### Python Client Code (extism-py WASM)
+
+Generated files are named `nd_host_.py` (lowercase) and placed in the `python/` subdirectory of the output directory. These files are intended for use in Navidrome plugins built with extism-py. Each file includes:
+
+- `# Code generated by hostgen. DO NOT EDIT.` header
+- Required imports (`dataclasses`, `typing`, `extism`, `json`)
+- `HostFunctionError` exception class for error handling
+- `@extism.import_fn` declarations for raw host functions
+- `@dataclass` types for methods with multiple return values
+- Wrapper functions with type hints, docstrings, and snake_case names
+
+#### Python Type Mapping
+
+| Go Type | Python Type |
+|-------------------------|-------------|
+| `string` | `str` |
+| `int`, `int32`, `int64` | `int` |
+| `float32`, `float64` | `float` |
+| `bool` | `bool` |
+| `[]byte` | `bytes` |
+| Unknown | `Any` |
+
+#### Python Function Naming
+
+Functions follow PEP 8 snake_case convention:
+
+| Go Method | Python Function |
+|-------------------------------|----------------------------------|
+| `SubsonicAPI.Call` | `subsonicapi_call()` |
+| `Scheduler.ScheduleRecurring` | `scheduler_schedule_recurring()` |
+| `Cache.GetString` | `cache_get_string()` |
+
+#### Multi-Value Returns
+
+Methods with multiple return values use dataclasses:
+
+```python
+@dataclass
+class CacheGetStringResult:
+ value: str
+ exists: bool
+
+def cache_get_string(key: str) -> CacheGetStringResult:
+ ...
+```
+
+#### Example Python Plugin Usage
+
+```python
+from nd_host_subsonicapi import subsonicapi_call, HostFunctionError
+
+try:
+ response = subsonicapi_call("getAlbumList2?type=random&size=10")
+ data = json.loads(response)
+except HostFunctionError as e:
+ print(f"API error: {e}")
```
## Troubleshooting
diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go
index 6385ee48f..2730f0470 100644
--- a/plugins/cmd/hostgen/integration_test.go
+++ b/plugins/cmd/hostgen/integration_test.go
@@ -205,15 +205,16 @@ type ServiceB interface {
Describe("code generation", func() {
DescribeTable("generates correct host and client output",
- func(serviceFile, hostExpectedFile, clientExpectedFile string) {
+ func(serviceFile, hostExpectedFile, goClientExpectedFile, pyClientExpectedFile string) {
serviceCode := readTestdata(serviceFile)
hostExpected := readTestdata(hostExpectedFile)
- clientExpected := readTestdata(clientExpectedFile)
+ goClientExpected := readTestdata(goClientExpectedFile)
+ pyClientExpected := readTestdata(pyClientExpectedFile)
Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
- // Generate both host and client code in one run
- cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg")
+ // Generate host and both Go and Python client code
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-go", "-python")
output, err := cmd.CombinedOutput()
Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
@@ -223,7 +224,7 @@ type ServiceB interface {
var hostFiles []string
for _, e := range entries {
- if e.Name() != "go" && !e.IsDir() {
+ if e.Name() != "go" && e.Name() != "python" && !e.IsDir() {
hostFiles = append(hostFiles, e.Name())
}
}
@@ -240,53 +241,64 @@ type ServiceB interface {
Expect(string(formattedHostActual)).To(Equal(string(formattedHostExpected)), "Host code mismatch")
- // Verify client code
+ // Verify Go client code
goDir := filepath.Join(outputDir, "go")
- clientEntries, err := os.ReadDir(goDir)
+ goClientEntries, err := os.ReadDir(goDir)
Expect(err).ToNot(HaveOccurred())
- Expect(clientEntries).To(HaveLen(1), "Expected exactly one client file")
+ Expect(goClientEntries).To(HaveLen(1), "Expected exactly one Go client file")
- clientActual, err := os.ReadFile(filepath.Join(goDir, clientEntries[0].Name()))
+ goClientActual, err := os.ReadFile(filepath.Join(goDir, goClientEntries[0].Name()))
Expect(err).ToNot(HaveOccurred())
- formattedClientActual, err := format.Source(clientActual)
- Expect(err).ToNot(HaveOccurred(), "Generated client code is not valid Go:\n%s", clientActual)
+ formattedGoClientActual, err := format.Source(goClientActual)
+ Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual)
- formattedClientExpected, err := format.Source([]byte(clientExpected))
- Expect(err).ToNot(HaveOccurred(), "Expected client code is not valid Go")
+ formattedGoClientExpected, err := format.Source([]byte(goClientExpected))
+ Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go")
- Expect(string(formattedClientActual)).To(Equal(string(formattedClientExpected)), "Client code mismatch")
+ Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch")
+
+ // Verify Python client code
+ pythonDir := filepath.Join(outputDir, "python")
+ pyClientEntries, err := os.ReadDir(pythonDir)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pyClientEntries).To(HaveLen(1), "Expected exactly one Python client file")
+
+ pyClientActual, err := os.ReadFile(filepath.Join(pythonDir, pyClientEntries[0].Name()))
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(string(pyClientActual)).To(Equal(pyClientExpected), "Python client code mismatch")
},
Entry("simple string params",
- "echo_service.go", "echo_expected.go", "echo_client_expected.go"),
+ "echo_service.go", "echo_expected.go", "echo_client_expected.go", "echo_client_expected.py"),
Entry("multiple simple params (int32)",
- "math_service.go", "math_expected.go", "math_client_expected.go"),
+ "math_service.go", "math_expected.go", "math_client_expected.go", "math_client_expected.py"),
Entry("struct param with request type",
- "store_service.go", "store_expected.go", "store_client_expected.go"),
+ "store_service.go", "store_expected.go", "store_client_expected.go", "store_client_expected.py"),
Entry("mixed simple and complex params",
- "list_service.go", "list_expected.go", "list_client_expected.go"),
+ "list_service.go", "list_expected.go", "list_client_expected.go", "list_client_expected.py"),
Entry("method without error",
- "counter_service.go", "counter_expected.go", "counter_client_expected.go"),
+ "counter_service.go", "counter_expected.go", "counter_client_expected.go", "counter_client_expected.py"),
Entry("no params, error only",
- "ping_service.go", "ping_expected.go", "ping_client_expected.go"),
+ "ping_service.go", "ping_expected.go", "ping_client_expected.go", "ping_client_expected.py"),
Entry("map and interface types",
- "meta_service.go", "meta_expected.go", "meta_client_expected.go"),
+ "meta_service.go", "meta_expected.go", "meta_client_expected.go", "meta_client_expected.py"),
Entry("pointer types",
- "users_service.go", "users_expected.go", "users_client_expected.go"),
+ "users_service.go", "users_expected.go", "users_client_expected.go", "users_client_expected.py"),
Entry("multiple returns",
- "search_service.go", "search_expected.go", "search_client_expected.go"),
+ "search_service.go", "search_expected.go", "search_client_expected.go", "search_client_expected.py"),
Entry("bytes",
- "codec_service.go", "codec_expected.go", "codec_client_expected.go"),
+ "codec_service.go", "codec_expected.go", "codec_client_expected.go", "codec_client_expected.py"),
)
It("generates compilable host code for comprehensive service", func() {
@@ -398,6 +410,120 @@ type Filter2 struct {
// Verify .wasm file was created
Expect(filepath.Join(clientDir, "plugin.wasm")).To(BeAnExistingFile())
})
+
+ It("generates Python client code with -python flag", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ DoAction(ctx context.Context, input string) (output string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-python", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Verify Python client code exists
+ pythonDir := filepath.Join(outputDir, "python")
+ Expect(pythonDir).To(BeADirectory())
+
+ pythonFile := filepath.Join(pythonDir, "nd_host_test.py")
+ Expect(pythonFile).To(BeAnExistingFile())
+
+ content, err := os.ReadFile(pythonFile)
+ Expect(err).ToNot(HaveOccurred())
+
+ contentStr := string(content)
+ Expect(contentStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
+ Expect(contentStr).To(ContainSubstring("class HostFunctionError(Exception):"))
+ Expect(contentStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "test_doaction")`))
+ Expect(contentStr).To(ContainSubstring("def test_do_action(input: str) -> str:"))
+ })
+
+ It("generates both Go and Python client code with -go -python flags", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ DoAction(ctx context.Context, input string) (output string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-go", "-python", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ // Verify both Go and Python client code exist
+ goDir := filepath.Join(outputDir, "go")
+ Expect(goDir).To(BeADirectory())
+ Expect(filepath.Join(goDir, "nd_host_test.go")).To(BeAnExistingFile())
+
+ pythonDir := filepath.Join(outputDir, "python")
+ Expect(pythonDir).To(BeADirectory())
+ Expect(filepath.Join(pythonDir, "nd_host_test.py")).To(BeAnExistingFile())
+ })
+
+ It("generates Python code with dataclass for multi-value returns", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Cache permission=cache
+type CacheService interface {
+ //nd:hostfunc
+ GetString(ctx context.Context, key string) (value string, exists bool, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-python", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_cache.py"))
+ Expect(err).ToNot(HaveOccurred())
+
+ contentStr := string(content)
+ Expect(contentStr).To(ContainSubstring("@dataclass"))
+ Expect(contentStr).To(ContainSubstring("class CacheGetStringResult:"))
+ Expect(contentStr).To(ContainSubstring("value: str"))
+ Expect(contentStr).To(ContainSubstring("exists: bool"))
+ Expect(contentStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
+ })
+
+ It("generates Python code for methods with no parameters", func() {
+ serviceCode := `package testpkg
+
+import "context"
+
+//nd:hostservice name=Test permission=test
+type TestService interface {
+ //nd:hostfunc
+ Ping(ctx context.Context) (status string, err error)
+}
+`
+ Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed())
+
+ cmd := exec.Command(hostgenBin, "-input", testDir, "-output", outputDir, "-package", "testpkg", "-python", "-plugin-only")
+ output, err := cmd.CombinedOutput()
+ Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output)
+
+ content, err := os.ReadFile(filepath.Join(outputDir, "python", "nd_host_test.py"))
+ Expect(err).ToNot(HaveOccurred())
+
+ contentStr := string(content)
+ Expect(contentStr).To(ContainSubstring("def test_ping() -> str:"))
+ Expect(contentStr).To(ContainSubstring(`request_bytes = b"{}"`))
+ })
})
})
diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go
index 4b609dcc4..d96614098 100644
--- a/plugins/cmd/hostgen/internal/generator.go
+++ b/plugins/cmd/hostgen/internal/generator.go
@@ -34,6 +34,17 @@ func clientFuncMap(svc Service) template.FuncMap {
}
}
+// pythonFuncMap returns the template functions for Python client code generation.
+func pythonFuncMap(svc Service) template.FuncMap {
+ return template.FuncMap{
+ "lower": strings.ToLower,
+ "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
+ "pythonFunc": func(m Method) string { return m.PythonFunctionName(svc.ExportPrefix()) },
+ "pythonResultType": func(m Method) string { return m.PythonResultTypeName(svc.Name) },
+ "pythonDefault": pythonDefaultValue,
+ }
+}
+
// GenerateHost generates the host function wrapper code for a service.
func GenerateHost(svc Service, pkgName string) ([]byte, error) {
tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl")
@@ -101,3 +112,45 @@ func formatDoc(doc string) string {
}
return strings.Join(result, "\n")
}
+
+// GenerateClientPython generates Python client wrapper code for plugins.
+func GenerateClientPython(svc Service) ([]byte, error) {
+ tmplContent, err := templatesFS.ReadFile("templates/client_py.py.tmpl")
+ if err != nil {
+ return nil, fmt.Errorf("reading Python client template: %w", err)
+ }
+
+ tmpl, err := template.New("client_py").Funcs(pythonFuncMap(svc)).Parse(string(tmplContent))
+ if err != nil {
+ return nil, fmt.Errorf("parsing template: %w", err)
+ }
+
+ data := templateData{
+ Service: svc,
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("executing template: %w", err)
+ }
+
+ return buf.Bytes(), nil
+}
+
+// pythonDefaultValue returns a Python default value for response.get() calls.
+func pythonDefaultValue(p Param) string {
+ switch p.Type {
+ case "string":
+ return `, ""`
+ case "int", "int32", "int64":
+ return ", 0"
+ case "float32", "float64":
+ return ", 0.0"
+ case "bool":
+ return ", False"
+ case "[]byte":
+ return ", b\"\""
+ default:
+ return ", None"
+ }
+}
diff --git a/plugins/cmd/hostgen/internal/generator_test.go b/plugins/cmd/hostgen/internal/generator_test.go
index 8b449527f..5d3c40ae3 100644
--- a/plugins/cmd/hostgen/internal/generator_test.go
+++ b/plugins/cmd/hostgen/internal/generator_test.go
@@ -310,6 +310,209 @@ var _ = Describe("Generator", func() {
})
})
+ Describe("Python type and name helpers", func() {
+ Describe("ToPythonType", func() {
+ It("should map Go types to Python types", func() {
+ Expect(ToPythonType("string")).To(Equal("str"))
+ Expect(ToPythonType("int")).To(Equal("int"))
+ Expect(ToPythonType("int32")).To(Equal("int"))
+ Expect(ToPythonType("int64")).To(Equal("int"))
+ Expect(ToPythonType("float32")).To(Equal("float"))
+ Expect(ToPythonType("float64")).To(Equal("float"))
+ Expect(ToPythonType("bool")).To(Equal("bool"))
+ Expect(ToPythonType("[]byte")).To(Equal("bytes"))
+ Expect(ToPythonType("unknown")).To(Equal("Any"))
+ })
+ })
+
+ Describe("ToSnakeCase", func() {
+ It("should convert PascalCase to snake_case", func() {
+ Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
+ Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
+ Expect(ToSnakeCase("ID")).To(Equal("i_d"))
+ Expect(ToSnakeCase("simple")).To(Equal("simple"))
+ })
+ })
+
+ Describe("Method.PythonFunctionName", func() {
+ It("should generate snake_case function name with service prefix", func() {
+ m := Method{Name: "GetString"}
+ Expect(m.PythonFunctionName("cache")).To(Equal("cache_get_string"))
+ })
+ })
+
+ Describe("Param.PythonType", func() {
+ It("should return Python type for parameter", func() {
+ p := NewParam("value", "string")
+ Expect(p.PythonType()).To(Equal("str"))
+ })
+ })
+
+ Describe("Param.PythonName", func() {
+ It("should return snake_case name for parameter", func() {
+ p := NewParam("ttlSeconds", "int64")
+ Expect(p.PythonName()).To(Equal("ttl_seconds"))
+ })
+ })
+ })
+
+ Describe("GenerateClientPython", func() {
+ It("should generate valid Python code for a simple service", func() {
+ svc := Service{
+ Name: "SubsonicAPI",
+ Permission: "subsonicapi",
+ Interface: "SubsonicAPIService",
+ Methods: []Method{
+ {
+ Name: "Call",
+ HasError: true,
+ Params: []Param{NewParam("uri", "string")},
+ Returns: []Param{NewParam("responseJSON", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for generated header
+ Expect(codeStr).To(ContainSubstring("Code generated by hostgen. DO NOT EDIT."))
+
+ // Check for imports
+ Expect(codeStr).To(ContainSubstring("from dataclasses import dataclass"))
+ Expect(codeStr).To(ContainSubstring("import extism"))
+ Expect(codeStr).To(ContainSubstring("import json"))
+
+ // Check for exception class
+ Expect(codeStr).To(ContainSubstring("class HostFunctionError(Exception):"))
+
+ // Check for raw import function
+ Expect(codeStr).To(ContainSubstring(`@extism.import_fn("extism:host/user", "subsonicapi_call")`))
+ Expect(codeStr).To(ContainSubstring("def _subsonicapi_call(offset: int) -> int:"))
+
+ // Check for wrapper function with type hints
+ Expect(codeStr).To(ContainSubstring("def subsonicapi_call(uri: str) -> str:"))
+
+ // Check for error handling
+ Expect(codeStr).To(ContainSubstring("raise HostFunctionError(response["))
+ })
+
+ It("should generate dataclass for multi-value returns", func() {
+ svc := Service{
+ Name: "Cache",
+ Permission: "cache",
+ Interface: "CacheService",
+ Methods: []Method{
+ {
+ Name: "GetString",
+ HasError: true,
+ Params: []Param{NewParam("key", "string")},
+ Returns: []Param{
+ NewParam("value", "string"),
+ NewParam("exists", "bool"),
+ },
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check for dataclass
+ Expect(codeStr).To(ContainSubstring("@dataclass"))
+ Expect(codeStr).To(ContainSubstring("class CacheGetStringResult:"))
+ Expect(codeStr).To(ContainSubstring("value: str"))
+ Expect(codeStr).To(ContainSubstring("exists: bool"))
+
+ // Check that function returns dataclass
+ Expect(codeStr).To(ContainSubstring("def cache_get_string(key: str) -> CacheGetStringResult:"))
+ Expect(codeStr).To(ContainSubstring("return CacheGetStringResult("))
+ })
+
+ It("should handle methods with no parameters", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "NoParams",
+ HasError: true,
+ Returns: []Param{NewParam("result", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Function with no params
+ Expect(codeStr).To(ContainSubstring("def test_no_params() -> str:"))
+ // Empty request
+ Expect(codeStr).To(ContainSubstring(`request_bytes = b"{}"`))
+ })
+
+ It("should handle methods with no return values", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "NoReturn",
+ HasError: true,
+ Params: []Param{NewParam("input", "string")},
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Function returns None
+ Expect(codeStr).To(ContainSubstring("def test_no_return(input: str) -> None:"))
+ })
+
+ It("should generate correct Python defaults for different types", func() {
+ svc := Service{
+ Name: "Test",
+ Permission: "test",
+ Interface: "TestService",
+ Methods: []Method{
+ {
+ Name: "AllTypes",
+ HasError: true,
+ Returns: []Param{
+ NewParam("strVal", "string"),
+ NewParam("intVal", "int64"),
+ NewParam("floatVal", "float64"),
+ NewParam("boolVal", "bool"),
+ },
+ },
+ },
+ }
+
+ code, err := GenerateClientPython(svc)
+ Expect(err).NotTo(HaveOccurred())
+
+ codeStr := string(code)
+
+ // Check defaults in response.get() calls
+ Expect(codeStr).To(ContainSubstring(`response.get("strVal", "")`))
+ Expect(codeStr).To(ContainSubstring(`response.get("intVal", 0)`))
+ Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
+ Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
+ })
+ })
+
Describe("Integration", func() {
It("should generate compilable code from parsed source", func() {
// This is an integration test that verifies the full pipeline
diff --git a/plugins/cmd/hostgen/internal/templates/client_py.py.tmpl b/plugins/cmd/hostgen/internal/templates/client_py.py.tmpl
new file mode 100644
index 000000000..8ae577e0e
--- /dev/null
+++ b/plugins/cmd/hostgen/internal/templates/client_py.py.tmpl
@@ -0,0 +1,94 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the {{.Service.Name}} host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_{{.Service.Name | lower}} import {{range $i, $m := .Service.Methods}}{{if $i}}, {{end}}{{pythonFunc $m}}{{end}}
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+{{- /* Generate raw host function imports */ -}}
+{{range .Service.Methods}}
+
+
+@extism.import_fn("extism:host/user", "{{exportName .}}")
+def _{{exportName .}}(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+{{- end}}
+{{- /* Generate dataclasses for multi-value returns */ -}}
+{{range .Service.Methods}}
+{{- if .NeedsResultClass}}
+
+
+@dataclass
+class {{pythonResultType .}}:
+ """Result type for {{pythonFunc .}}."""
+{{- range .Returns}}
+ {{.PythonName}}: {{.PythonType}}
+{{- end}}
+{{- end}}
+{{- end}}
+{{- /* Generate wrapper functions */ -}}
+{{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}}:
+ """{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
+{{- if .HasParams}}
+
+ Args:
+{{- range .Params}}
+ {{.PythonName}}: {{.PythonType}} parameter.
+{{- end}}
+{{- end}}
+{{- if .HasReturns}}
+
+ Returns:
+{{- if .NeedsResultClass}}
+ {{pythonResultType .}} containing{{range .Returns}} {{.PythonName}},{{end}}.
+{{- else}}
+ {{(index .Returns 0).PythonType}}: The result value.
+{{- end}}
+{{- end}}
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+{{- if .HasParams}}
+ request = {
+{{- range .Params}}
+ "{{.JSONName}}": {{.PythonName}},
+{{- end}}
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+{{- else}}
+ request_bytes = b"{}"
+{{- end}}
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _{{exportName .}}(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+{{if .NeedsResultClass}}
+ return {{pythonResultType .}}(
+{{- range .Returns}}
+ {{.PythonName}}=response.get("{{.JSONName}}"{{pythonDefault .}}),
+{{- end}}
+ )
+{{- else if .HasReturns}}
+ return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
+{{- end}}
+{{- end}}
diff --git a/plugins/cmd/hostgen/internal/types.go b/plugins/cmd/hostgen/internal/types.go
index 8cea8d696..9bcc7a190 100644
--- a/plugins/cmd/hostgen/internal/types.go
+++ b/plugins/cmd/hostgen/internal/types.go
@@ -85,3 +85,58 @@ func toJSONName(name string) string {
// Simple conversion: lowercase first letter
return strings.ToLower(name[:1]) + name[1:]
}
+
+// ToPythonType converts a Go type to its Python equivalent.
+func ToPythonType(goType string) string {
+ switch goType {
+ case "string":
+ return "str"
+ case "int", "int32", "int64":
+ return "int"
+ case "float32", "float64":
+ return "float"
+ case "bool":
+ return "bool"
+ case "[]byte":
+ return "bytes"
+ default:
+ return "Any"
+ }
+}
+
+// ToSnakeCase converts a PascalCase or camelCase string to snake_case.
+func ToSnakeCase(s string) string {
+ var result strings.Builder
+ for i, r := range s {
+ if i > 0 && r >= 'A' && r <= 'Z' {
+ result.WriteByte('_')
+ }
+ result.WriteRune(r)
+ }
+ return strings.ToLower(result.String())
+}
+
+// PythonFunctionName returns the Python function name for a method.
+func (m Method) PythonFunctionName(servicePrefix string) string {
+ return ToSnakeCase(servicePrefix + m.Name)
+}
+
+// PythonResultTypeName returns the Python dataclass name for multi-value returns.
+func (m Method) PythonResultTypeName(serviceName string) string {
+ return serviceName + m.Name + "Result"
+}
+
+// NeedsResultClass returns true if the method needs a dataclass for returns.
+func (m Method) NeedsResultClass() bool {
+ return len(m.Returns) > 1
+}
+
+// PythonType returns the Python type for this parameter.
+func (p Param) PythonType() string {
+ return ToPythonType(p.Type)
+}
+
+// PythonName returns the snake_case Python name for this parameter.
+func (p Param) PythonName() string {
+ return ToSnakeCase(p.Name)
+}
diff --git a/plugins/cmd/hostgen/main.go b/plugins/cmd/hostgen/main.go
index 4b3d600cf..c57c12536 100644
--- a/plugins/cmd/hostgen/main.go
+++ b/plugins/cmd/hostgen/main.go
@@ -11,6 +11,8 @@
// -package Output package name (default: inferred from output directory)
// -host-only Generate only host-side code (default: false)
// -plugin-only Generate only plugin/client-side code (default: false)
+// -go Generate Go client wrappers (default: true when not using -python)
+// -python Generate Python client wrappers (default: false)
// -v Verbose output
// -dry-run Preview generated code without writing files
package main
@@ -33,6 +35,8 @@ func main() {
pkgName = flag.String("package", "", "Output package name (default: inferred from output directory)")
hostOnly = flag.Bool("host-only", false, "Generate only host-side code")
pluginOnly = flag.Bool("plugin-only", false, "Generate only plugin/client-side code")
+ goClient = flag.Bool("go", false, "Generate Go client wrappers")
+ pyClient = flag.Bool("python", false, "Generate Python client wrappers")
verbose = flag.Bool("v", false, "Verbose output")
dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files")
)
@@ -67,14 +71,20 @@ func main() {
// Determine what to generate
generateHost := !*pluginOnly
- generateClient := !*hostOnly
+ // Default: generate Go clients if no language flag is specified
+ // If -python is specified without -go, only generate Python
+ // If -go is specified, generate Go
+ // If both are specified, generate both
+ generateGoClient := !*hostOnly && (*goClient || !*pyClient)
+ generatePyClient := !*hostOnly && *pyClient
if *verbose {
fmt.Printf("Input directory: %s\n", absInput)
fmt.Printf("Output directory: %s\n", absOutput)
fmt.Printf("Package name: %s\n", *pkgName)
fmt.Printf("Generate host code: %v\n", generateHost)
- fmt.Printf("Generate client code: %v\n", generateClient)
+ fmt.Printf("Generate Go client code: %v\n", generateGoClient)
+ fmt.Printf("Generate Python client code: %v\n", generatePyClient)
}
// Parse source files
@@ -108,10 +118,18 @@ func main() {
}
}
- // Generate client-side code
- if generateClient {
- if err := generateClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
- fmt.Fprintf(os.Stderr, "Error generating client code for %s: %v\n", svc.Name, err)
+ // Generate Go client-side code
+ if generateGoClient {
+ if err := generateGoClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating Go client code for %s: %v\n", svc.Name, err)
+ os.Exit(1)
+ }
+ }
+
+ // Generate Python client-side code
+ if generatePyClient {
+ if err := generatePythonClientCode(svc, absOutput, *dryRun, *verbose); err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating Python client code for %s: %v\n", svc.Name, err)
os.Exit(1)
}
}
@@ -147,8 +165,8 @@ func generateHostCode(svc internal.Service, pkgName, outputDir string, dryRun, v
return nil
}
-// generateClientCode generates client-side code for a service.
-func generateClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
+// generateGoClientCode generates Go client-side code for a service.
+func generateGoClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
code, err := internal.GenerateClientGo(svc)
if err != nil {
return fmt.Errorf("generating code: %w", err)
@@ -178,7 +196,38 @@ func generateClientCode(svc internal.Service, outputDir string, dryRun, verbose
}
if verbose {
- fmt.Printf("Generated client code: %s\n", clientFile)
+ fmt.Printf("Generated Go client code: %s\n", clientFile)
+ }
+ return nil
+}
+
+// generatePythonClientCode generates Python client-side code for a service.
+func generatePythonClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error {
+ code, err := internal.GenerateClientPython(svc)
+ if err != nil {
+ return fmt.Errorf("generating code: %w", err)
+ }
+
+ // Python code goes in python/ subdirectory
+ clientDir := filepath.Join(outputDir, "python")
+ clientFile := filepath.Join(clientDir, "nd_host_"+strings.ToLower(svc.Name)+".py")
+
+ if dryRun {
+ fmt.Printf("=== %s ===\n%s\n", clientFile, code)
+ return nil
+ }
+
+ // Create python/ subdirectory if needed
+ if err := os.MkdirAll(clientDir, 0755); err != nil {
+ return fmt.Errorf("creating python client directory: %w", err)
+ }
+
+ if err := os.WriteFile(clientFile, code, 0600); err != nil {
+ return fmt.Errorf("writing file: %w", err)
+ }
+
+ if verbose {
+ fmt.Printf("Generated Python client code: %s\n", clientFile)
}
return nil
}
diff --git a/plugins/cmd/hostgen/testdata/codec_client_expected.py b/plugins/cmd/hostgen/testdata/codec_client_expected.py
new file mode 100644
index 000000000..6e17a684a
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/codec_client_expected.py
@@ -0,0 +1,51 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Codec host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_codec import codec_encode
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "codec_encode")
+def _codec_encode(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def codec_encode(data: bytes) -> bytes:
+ """Call the codec_encode host function.
+
+ Args:
+ data: bytes parameter.
+
+ Returns:
+ bytes: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "data": data,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _codec_encode(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("result", b"")
diff --git a/plugins/cmd/hostgen/testdata/counter_client_expected.py b/plugins/cmd/hostgen/testdata/counter_client_expected.py
new file mode 100644
index 000000000..4bce404a3
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/counter_client_expected.py
@@ -0,0 +1,51 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Counter host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_counter import counter_count
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "counter_count")
+def _counter_count(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def counter_count(name: str) -> int:
+ """Call the counter_count host function.
+
+ Args:
+ name: str parameter.
+
+ Returns:
+ int: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "name": name,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _counter_count(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("value", 0)
diff --git a/plugins/cmd/hostgen/testdata/echo_client_expected.py b/plugins/cmd/hostgen/testdata/echo_client_expected.py
new file mode 100644
index 000000000..6de0d2c21
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/echo_client_expected.py
@@ -0,0 +1,51 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Echo host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_echo import echo_echo
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "echo_echo")
+def _echo_echo(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def echo_echo(message: str) -> str:
+ """Call the echo_echo host function.
+
+ Args:
+ message: str parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "message": message,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _echo_echo(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("reply", "")
diff --git a/plugins/cmd/hostgen/testdata/list_client_expected.py b/plugins/cmd/hostgen/testdata/list_client_expected.py
new file mode 100644
index 000000000..0be0e2a76
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/list_client_expected.py
@@ -0,0 +1,53 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the List host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_list import list_items
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "list_items")
+def _list_items(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def list_items(name: str, filter: Any) -> int:
+ """Call the list_items host function.
+
+ Args:
+ name: str parameter.
+ filter: Any parameter.
+
+ Returns:
+ int: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "name": name,
+ "filter": filter,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _list_items(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("count", 0)
diff --git a/plugins/cmd/hostgen/testdata/math_client_expected.py b/plugins/cmd/hostgen/testdata/math_client_expected.py
new file mode 100644
index 000000000..dfaa895a2
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/math_client_expected.py
@@ -0,0 +1,53 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Math host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_math import math_add
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "math_add")
+def _math_add(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def math_add(a: int, b: int) -> int:
+ """Call the math_add host function.
+
+ Args:
+ a: int parameter.
+ b: int parameter.
+
+ Returns:
+ int: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "a": a,
+ "b": b,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _math_add(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("result", 0)
diff --git a/plugins/cmd/hostgen/testdata/meta_client_expected.py b/plugins/cmd/hostgen/testdata/meta_client_expected.py
new file mode 100644
index 000000000..f900d0811
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/meta_client_expected.py
@@ -0,0 +1,80 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Meta host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_meta import meta_get, meta_set
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "meta_get")
+def _meta_get(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "meta_set")
+def _meta_set(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def meta_get(key: str) -> Any:
+ """Call the meta_get host function.
+
+ Args:
+ key: str parameter.
+
+ Returns:
+ Any: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _meta_get(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("value", None)
+
+
+def meta_set(data: Any) -> None:
+ """Call the meta_set host function.
+
+ Args:
+ data: Any parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "data": data,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _meta_set(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
diff --git a/plugins/cmd/hostgen/testdata/ping_client_expected.py b/plugins/cmd/hostgen/testdata/ping_client_expected.py
new file mode 100644
index 000000000..503892778
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/ping_client_expected.py
@@ -0,0 +1,41 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Ping host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_ping import ping_ping
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "ping_ping")
+def _ping_ping(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def ping_ping() -> None:
+ """Call the ping_ping host function.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request_bytes = b"{}"
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _ping_ping(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
diff --git a/plugins/cmd/hostgen/testdata/search_client_expected.py b/plugins/cmd/hostgen/testdata/search_client_expected.py
new file mode 100644
index 000000000..9befe7ab4
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/search_client_expected.py
@@ -0,0 +1,61 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Search host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_search import search_find
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "search_find")
+def _search_find(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@dataclass
+class SearchFindResult:
+ """Result type for search_find."""
+ results: Any
+ total: int
+
+
+def search_find(query: str) -> SearchFindResult:
+ """Call the search_find host function.
+
+ Args:
+ query: str parameter.
+
+ Returns:
+ SearchFindResult containing results, total,.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "query": query,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _search_find(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return SearchFindResult(
+ results=response.get("results", None),
+ total=response.get("total", 0),
+ )
diff --git a/plugins/cmd/hostgen/testdata/store_client_expected.py b/plugins/cmd/hostgen/testdata/store_client_expected.py
new file mode 100644
index 000000000..fd8ca5e40
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/store_client_expected.py
@@ -0,0 +1,51 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Store host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_store import store_save
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "store_save")
+def _store_save(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def store_save(item: Any) -> str:
+ """Call the store_save host function.
+
+ Args:
+ item: Any parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "item": item,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _store_save(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("id", "")
diff --git a/plugins/cmd/hostgen/testdata/users_client_expected.py b/plugins/cmd/hostgen/testdata/users_client_expected.py
new file mode 100644
index 000000000..67927d950
--- /dev/null
+++ b/plugins/cmd/hostgen/testdata/users_client_expected.py
@@ -0,0 +1,53 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Users host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_users import users_get
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "users_get")
+def _users_get(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def users_get(id: Any, filter: Any) -> Any:
+ """Call the users_get host function.
+
+ Args:
+ id: Any parameter.
+ filter: Any parameter.
+
+ Returns:
+ Any: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "id": id,
+ "filter": filter,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _users_get(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("result", None)
diff --git a/plugins/host/python/nd_host_artwork.py b/plugins/host/python/nd_host_artwork.py
new file mode 100644
index 000000000..a41762cee
--- /dev/null
+++ b/plugins/host/python/nd_host_artwork.py
@@ -0,0 +1,182 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Artwork host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_artwork import artwork_get_artist_url, artwork_get_album_url, artwork_get_track_url, artwork_get_playlist_url
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "artwork_getartisturl")
+def _artwork_getartisturl(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "artwork_getalbumurl")
+def _artwork_getalbumurl(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "artwork_gettrackurl")
+def _artwork_gettrackurl(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "artwork_getplaylisturl")
+def _artwork_getplaylisturl(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def artwork_get_artist_url(id: str, size: int) -> str:
+ """GetArtistUrl generates a public URL for an artist's artwork.
+
+Parameters:
+ - id: The artist's unique identifier
+ - size: Desired image size in pixels (0 for original size)
+
+Returns the public URL for the artwork, or an error if generation fails.
+
+ Args:
+ id: str parameter.
+ size: int parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "id": id,
+ "size": size,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _artwork_getartisturl(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("url", "")
+
+
+def artwork_get_album_url(id: str, size: int) -> str:
+ """GetAlbumUrl generates a public URL for an album's artwork.
+
+Parameters:
+ - id: The album's unique identifier
+ - size: Desired image size in pixels (0 for original size)
+
+Returns the public URL for the artwork, or an error if generation fails.
+
+ Args:
+ id: str parameter.
+ size: int parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "id": id,
+ "size": size,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _artwork_getalbumurl(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("url", "")
+
+
+def artwork_get_track_url(id: str, size: int) -> str:
+ """GetTrackUrl generates a public URL for a track's artwork.
+
+Parameters:
+ - id: The track's (media file) unique identifier
+ - size: Desired image size in pixels (0 for original size)
+
+Returns the public URL for the artwork, or an error if generation fails.
+
+ Args:
+ id: str parameter.
+ size: int parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "id": id,
+ "size": size,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _artwork_gettrackurl(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("url", "")
+
+
+def artwork_get_playlist_url(id: str, size: int) -> str:
+ """GetPlaylistUrl generates a public URL for a playlist's artwork.
+
+Parameters:
+ - id: The playlist's unique identifier
+ - size: Desired image size in pixels (0 for original size)
+
+Returns the public URL for the artwork, or an error if generation fails.
+
+ Args:
+ id: str parameter.
+ size: int parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "id": id,
+ "size": size,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _artwork_getplaylisturl(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("url", "")
diff --git a/plugins/host/python/nd_host_cache.py b/plugins/host/python/nd_host_cache.py
new file mode 100644
index 000000000..8a91cfea9
--- /dev/null
+++ b/plugins/host/python/nd_host_cache.py
@@ -0,0 +1,446 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Cache host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_cache import cache_set_string, cache_get_string, cache_set_int, cache_get_int, cache_set_float, cache_get_float, cache_set_bytes, cache_get_bytes, cache_has, cache_remove
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "cache_setstring")
+def _cache_setstring(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_getstring")
+def _cache_getstring(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_setint")
+def _cache_setint(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_getint")
+def _cache_getint(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_setfloat")
+def _cache_setfloat(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_getfloat")
+def _cache_getfloat(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_setbytes")
+def _cache_setbytes(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_getbytes")
+def _cache_getbytes(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_has")
+def _cache_has(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "cache_remove")
+def _cache_remove(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@dataclass
+class CacheGetStringResult:
+ """Result type for cache_get_string."""
+ value: str
+ exists: bool
+
+
+@dataclass
+class CacheGetIntResult:
+ """Result type for cache_get_int."""
+ value: int
+ exists: bool
+
+
+@dataclass
+class CacheGetFloatResult:
+ """Result type for cache_get_float."""
+ value: float
+ exists: bool
+
+
+@dataclass
+class CacheGetBytesResult:
+ """Result type for cache_get_bytes."""
+ value: bytes
+ exists: bool
+
+
+def cache_set_string(key: str, value: str, ttl_seconds: int) -> None:
+ """SetString stores a string value in the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+ - value: The string value to store
+ - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
+
+Returns an error if the operation fails.
+
+ Args:
+ key: str parameter.
+ value: str parameter.
+ ttl_seconds: int parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ "value": value,
+ "ttlSeconds": ttl_seconds,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_setstring(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+
+
+def cache_get_string(key: str) -> CacheGetStringResult:
+ """GetString retrieves a string value from the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+
+Returns the value and whether the key exists. If the key doesn't exist
+or the stored value is not a string, exists will be false.
+
+ Args:
+ key: str parameter.
+
+ Returns:
+ CacheGetStringResult containing value, exists,.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_getstring(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return CacheGetStringResult(
+ value=response.get("value", ""),
+ exists=response.get("exists", False),
+ )
+
+
+def cache_set_int(key: str, value: int, ttl_seconds: int) -> None:
+ """SetInt stores an integer value in the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+ - value: The integer value to store
+ - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
+
+Returns an error if the operation fails.
+
+ Args:
+ key: str parameter.
+ value: int parameter.
+ ttl_seconds: int parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ "value": value,
+ "ttlSeconds": ttl_seconds,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_setint(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+
+
+def cache_get_int(key: str) -> CacheGetIntResult:
+ """GetInt retrieves an integer value from the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+
+Returns the value and whether the key exists. If the key doesn't exist
+or the stored value is not an integer, exists will be false.
+
+ Args:
+ key: str parameter.
+
+ Returns:
+ CacheGetIntResult containing value, exists,.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_getint(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return CacheGetIntResult(
+ value=response.get("value", 0),
+ exists=response.get("exists", False),
+ )
+
+
+def cache_set_float(key: str, value: float, ttl_seconds: int) -> None:
+ """SetFloat stores a float value in the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+ - value: The float value to store
+ - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
+
+Returns an error if the operation fails.
+
+ Args:
+ key: str parameter.
+ value: float parameter.
+ ttl_seconds: int parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ "value": value,
+ "ttlSeconds": ttl_seconds,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_setfloat(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+
+
+def cache_get_float(key: str) -> CacheGetFloatResult:
+ """GetFloat retrieves a float value from the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+
+Returns the value and whether the key exists. If the key doesn't exist
+or the stored value is not a float, exists will be false.
+
+ Args:
+ key: str parameter.
+
+ Returns:
+ CacheGetFloatResult containing value, exists,.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_getfloat(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return CacheGetFloatResult(
+ value=response.get("value", 0.0),
+ exists=response.get("exists", False),
+ )
+
+
+def cache_set_bytes(key: str, value: bytes, ttl_seconds: int) -> None:
+ """SetBytes stores a byte slice in the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+ - value: The byte slice to store
+ - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours)
+
+Returns an error if the operation fails.
+
+ Args:
+ key: str parameter.
+ value: bytes parameter.
+ ttl_seconds: int parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ "value": value,
+ "ttlSeconds": ttl_seconds,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_setbytes(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+
+
+def cache_get_bytes(key: str) -> CacheGetBytesResult:
+ """GetBytes retrieves a byte slice from the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+
+Returns the value and whether the key exists. If the key doesn't exist
+or the stored value is not a byte slice, exists will be false.
+
+ Args:
+ key: str parameter.
+
+ Returns:
+ CacheGetBytesResult containing value, exists,.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_getbytes(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return CacheGetBytesResult(
+ value=response.get("value", b""),
+ exists=response.get("exists", False),
+ )
+
+
+def cache_has(key: str) -> bool:
+ """Has checks if a key exists in the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+
+Returns true if the key exists and has not expired.
+
+ Args:
+ key: str parameter.
+
+ Returns:
+ bool: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_has(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("exists", False)
+
+
+def cache_remove(key: str) -> None:
+ """Remove deletes a value from the cache.
+
+Parameters:
+ - key: The cache key (will be namespaced with plugin ID)
+
+Returns an error if the operation fails. Does not return an error if the key doesn't exist.
+
+ Args:
+ key: str parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "key": key,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _cache_remove(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
diff --git a/plugins/host/python/nd_host_scheduler.py b/plugins/host/python/nd_host_scheduler.py
new file mode 100644
index 000000000..f7b8e320b
--- /dev/null
+++ b/plugins/host/python/nd_host_scheduler.py
@@ -0,0 +1,142 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the Scheduler host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_scheduler import scheduler_schedule_one_time, scheduler_schedule_recurring, scheduler_cancel_schedule
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "scheduler_scheduleonetime")
+def _scheduler_scheduleonetime(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "scheduler_schedulerecurring")
+def _scheduler_schedulerecurring(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "scheduler_cancelschedule")
+def _scheduler_cancelschedule(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def scheduler_schedule_one_time(delay_seconds: int, payload: str, schedule_i_d: str) -> str:
+ """ScheduleOneTime schedules a one-time event to be triggered after the specified delay.
+Plugins that use this function must also implement the SchedulerCallback capability
+
+Parameters:
+ - delaySeconds: Number of seconds to wait before triggering the event
+ - payload: Data to be passed to the scheduled event handler
+ - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
+
+Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
+
+ Args:
+ delay_seconds: int parameter.
+ payload: str parameter.
+ schedule_i_d: str parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "delaySeconds": delay_seconds,
+ "payload": payload,
+ "scheduleID": schedule_i_d,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _scheduler_scheduleonetime(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("newScheduleID", "")
+
+
+def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_i_d: str) -> str:
+ """ScheduleRecurring schedules a recurring event using a cron expression.
+Plugins that use this function must also implement the SchedulerCallback capability
+
+Parameters:
+ - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight)
+ - payload: Data to be passed to each scheduled event handler invocation
+ - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated
+
+Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails.
+
+ Args:
+ cron_expression: str parameter.
+ payload: str parameter.
+ schedule_i_d: str parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "cronExpression": cron_expression,
+ "payload": payload,
+ "scheduleID": schedule_i_d,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _scheduler_schedulerecurring(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("newScheduleID", "")
+
+
+def scheduler_cancel_schedule(schedule_i_d: str) -> None:
+ """CancelSchedule cancels a scheduled job identified by its schedule ID.
+
+This works for both one-time and recurring schedules. Once cancelled, the job will not trigger
+any future events.
+
+Returns an error if the schedule ID is not found or if cancellation fails.
+
+ Args:
+ schedule_i_d: str parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "scheduleID": schedule_i_d,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _scheduler_cancelschedule(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
diff --git a/plugins/host/python/nd_host_subsonicapi.py b/plugins/host/python/nd_host_subsonicapi.py
new file mode 100644
index 000000000..baabad0ec
--- /dev/null
+++ b/plugins/host/python/nd_host_subsonicapi.py
@@ -0,0 +1,54 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the SubsonicAPI host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_subsonicapi import subsonicapi_call
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "subsonicapi_call")
+def _subsonicapi_call(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.
+
+The uri parameter should be the Subsonic API path without the server prefix,
+e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
+
+ Args:
+ uri: str parameter.
+
+ Returns:
+ str: The result value.
+
+ 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_call(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("responseJSON", "")
diff --git a/plugins/host/python/nd_host_websocket.py b/plugins/host/python/nd_host_websocket.py
new file mode 100644
index 000000000..37fff3d1f
--- /dev/null
+++ b/plugins/host/python/nd_host_websocket.py
@@ -0,0 +1,180 @@
+# Code generated by hostgen. DO NOT EDIT.
+#
+# This file contains client wrappers for the WebSocket host service.
+# It is intended for use in Navidrome plugins built with extism-py.
+#
+# Usage:
+# from nd_host_websocket import websocket_connect, websocket_send_text, websocket_send_binary, websocket_close_connection
+
+from dataclasses import dataclass
+from typing import Any
+
+import extism
+import json
+
+
+class HostFunctionError(Exception):
+ """Raised when a host function returns an error."""
+ pass
+
+
+@extism.import_fn("extism:host/user", "websocket_connect")
+def _websocket_connect(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "websocket_sendtext")
+def _websocket_sendtext(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "websocket_sendbinary")
+def _websocket_sendbinary(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+@extism.import_fn("extism:host/user", "websocket_closeconnection")
+def _websocket_closeconnection(offset: int) -> int:
+ """Raw host function - do not call directly."""
+ ...
+
+
+def websocket_connect(url: str, headers: Any, connection_i_d: str) -> str:
+ """Connect establishes a WebSocket connection to the specified URL.
+
+Plugins that use this function must also implement the WebSocketCallback capability
+to receive incoming messages and connection events.
+
+Parameters:
+ - url: The WebSocket URL to connect to (ws:// or wss://)
+ - headers: Optional HTTP headers to include in the handshake request
+ - connectionID: Optional unique identifier for the connection. If empty, one will be generated
+
+Returns the connection ID that can be used to send messages or close the connection,
+or an error if the connection fails.
+
+ Args:
+ url: str parameter.
+ headers: Any parameter.
+ connection_i_d: str parameter.
+
+ Returns:
+ str: The result value.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "url": url,
+ "headers": headers,
+ "connectionID": connection_i_d,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _websocket_connect(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+ return response.get("newConnectionID", "")
+
+
+def websocket_send_text(connection_i_d: str, message: str) -> None:
+ """SendText sends a text message over an established WebSocket connection.
+
+Parameters:
+ - connectionID: The connection identifier returned by Connect
+ - message: The text message to send
+
+Returns an error if the connection is not found or if sending fails.
+
+ Args:
+ connection_i_d: str parameter.
+ message: str parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "connectionID": connection_i_d,
+ "message": message,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _websocket_sendtext(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+
+
+def websocket_send_binary(connection_i_d: str, data: bytes) -> None:
+ """SendBinary sends binary data over an established WebSocket connection.
+
+Parameters:
+ - connectionID: The connection identifier returned by Connect
+ - data: The binary data to send
+
+Returns an error if the connection is not found or if sending fails.
+
+ Args:
+ connection_i_d: str parameter.
+ data: bytes parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "connectionID": connection_i_d,
+ "data": data,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _websocket_sendbinary(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+
+
+
+def websocket_close_connection(connection_i_d: str, code: int, reason: str) -> None:
+ """CloseConnection gracefully closes a WebSocket connection.
+
+Parameters:
+ - connectionID: The connection identifier returned by Connect
+ - code: WebSocket close status code (e.g., 1000 for normal closure)
+ - reason: Optional human-readable reason for closing
+
+Returns an error if the connection is not found or if closing fails.
+
+ Args:
+ connection_i_d: str parameter.
+ code: int parameter.
+ reason: str parameter.
+
+ Raises:
+ HostFunctionError: If the host function returns an error.
+ """
+ request = {
+ "connectionID": connection_i_d,
+ "code": code,
+ "reason": reason,
+ }
+ request_bytes = json.dumps(request).encode("utf-8")
+ request_mem = extism.memory.alloc(request_bytes)
+ response_offset = _websocket_closeconnection(request_mem.offset)
+ response_mem = extism.memory.find(response_offset)
+ response = json.loads(extism.memory.string(response_mem))
+
+ if response.get("error"):
+ raise HostFunctionError(response["error"])
+