mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(library): add Library service for metadata access and filesystem integration
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
9a070a63fc
commit
eef9599013
@ -15,6 +15,7 @@ Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugi
|
||||
- [Scheduler](#scheduler)
|
||||
- [Cache](#cache)
|
||||
- [WebSocket](#websocket)
|
||||
- [Library](#library)
|
||||
- [Artwork](#artwork)
|
||||
- [SubsonicAPI](#subsonicapi)
|
||||
- [Configuration](#configuration)
|
||||
@ -404,6 +405,100 @@ Establish persistent WebSocket connections to external services.
|
||||
| `nd_websocket_on_error` | `{connection_id, error}` | Connection error |
|
||||
| `nd_websocket_on_close` | `{connection_id, code, reason}` | Connection closed |
|
||||
|
||||
### Library
|
||||
|
||||
Access music library metadata and optionally read files from library directories.
|
||||
|
||||
**Manifest permission:**
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"library": {
|
||||
"reason": "Access library metadata for analysis",
|
||||
"filesystem": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`)
|
||||
|
||||
**Host functions:**
|
||||
|
||||
| Function | Parameters | Returns |
|
||||
|----------------------------|------------|---------------------------|
|
||||
| `library_getlibrary` | `id` | Library metadata |
|
||||
| `library_getalllibraries` | (none) | Array of library metadata |
|
||||
|
||||
**Library metadata:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "My Music",
|
||||
"path": "/music/collection",
|
||||
"mountPoint": "/libraries/1",
|
||||
"lastScanAt": 1703270400,
|
||||
"totalSongs": 5000,
|
||||
"totalAlbums": 500,
|
||||
"totalArtists": 200,
|
||||
"totalSize": 50000000000,
|
||||
"totalDuration": 1500000.5
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** The `path` and `mountPoint` fields are only included when `filesystem: true` is set in the permission.
|
||||
|
||||
**Filesystem access:**
|
||||
|
||||
When `filesystem: true`, your plugin can read files from library directories via WASI filesystem APIs. Each library is mounted at `/libraries/<id>`:
|
||||
|
||||
```go
|
||||
import "os"
|
||||
|
||||
// Read a file from library 1
|
||||
content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3")
|
||||
|
||||
// List directory contents
|
||||
entries, err := os.ReadDir("/libraries/1/Artist")
|
||||
```
|
||||
|
||||
> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem.
|
||||
|
||||
**Usage (with generated SDK):**
|
||||
|
||||
Copy `plugins/host/go/nd_host_library.go` to your plugin. You'll also need to add the `Library` struct definition:
|
||||
|
||||
```go
|
||||
// Library represents a music library with metadata.
|
||||
type Library struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MountPoint string `json:"mountPoint,omitempty"`
|
||||
LastScanAt int64 `json:"lastScanAt"`
|
||||
TotalSongs int32 `json:"totalSongs"`
|
||||
TotalAlbums int32 `json:"totalAlbums"`
|
||||
TotalArtists int32 `json:"totalArtists"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TotalDuration float64 `json:"totalDuration"`
|
||||
}
|
||||
|
||||
// Get a specific library
|
||||
resp, err := LibraryGetLibrary(1)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
library := resp.Result
|
||||
|
||||
// Get all libraries
|
||||
resp, err := LibraryGetAllLibraries()
|
||||
for _, lib := range resp.Result {
|
||||
fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs)
|
||||
}
|
||||
```
|
||||
|
||||
### Artwork
|
||||
|
||||
Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists).
|
||||
@ -565,7 +660,7 @@ See [examples/](examples/) for complete working plugins:
|
||||
Plugins run in a secure WebAssembly sandbox:
|
||||
|
||||
1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket
|
||||
2. **No File System** – Plugins cannot access the file system
|
||||
2. **Limited File System** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only
|
||||
3. **No Network Listeners** – Plugins cannot bind ports
|
||||
4. **Config Isolation** – Plugins only receive their own config section
|
||||
5. **Memory Limits** – Controlled by the WebAssembly runtime
|
||||
|
||||
112
plugins/host/go/nd_host_library.go
Normal file
112
plugins/host/go/nd_host_library.go
Normal file
@ -0,0 +1,112 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Library host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// library_getlibrary is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user library_getlibrary
|
||||
func library_getlibrary(uint64) uint64
|
||||
|
||||
// library_getalllibraries is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user library_getalllibraries
|
||||
func library_getalllibraries(uint64) uint64
|
||||
|
||||
// LibraryGetLibraryRequest is the request type for Library.GetLibrary.
|
||||
type LibraryGetLibraryRequest struct {
|
||||
Id int32 `json:"id"`
|
||||
}
|
||||
|
||||
// LibraryGetLibraryResponse is the response type for Library.GetLibrary.
|
||||
type LibraryGetLibraryResponse struct {
|
||||
Result *Library `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryGetAllLibrariesResponse is the response type for Library.GetAllLibraries.
|
||||
type LibraryGetAllLibrariesResponse struct {
|
||||
Result []Library `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryGetLibrary calls the library_getlibrary host function.
|
||||
// GetLibrary retrieves metadata for a specific library by ID.
|
||||
//
|
||||
// Parameters:
|
||||
// - id: The library's unique identifier
|
||||
//
|
||||
// Returns the library metadata, or an error if the library is not found.
|
||||
func LibraryGetLibrary(id int32) (*LibraryGetLibraryResponse, error) {
|
||||
// Marshal request to JSON
|
||||
req := LibraryGetLibraryRequest{
|
||||
Id: id,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := library_getlibrary(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response LibraryGetLibraryResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// LibraryGetAllLibraries calls the library_getalllibraries host function.
|
||||
// GetAllLibraries retrieves metadata for all configured libraries.
|
||||
//
|
||||
// Returns a slice of all libraries with their metadata.
|
||||
func LibraryGetAllLibraries() (*LibraryGetAllLibrariesResponse, error) {
|
||||
// No parameters - allocate empty JSON object
|
||||
reqMem := pdk.AllocateBytes([]byte("{}"))
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := library_getalllibraries(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response LibraryGetAllLibrariesResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
41
plugins/host/library.go
Normal file
41
plugins/host/library.go
Normal file
@ -0,0 +1,41 @@
|
||||
package host
|
||||
|
||||
import "context"
|
||||
|
||||
// Library represents a music library with metadata.
|
||||
type Library struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MountPoint string `json:"mountPoint,omitempty"`
|
||||
LastScanAt int64 `json:"lastScanAt"`
|
||||
TotalSongs int32 `json:"totalSongs"`
|
||||
TotalAlbums int32 `json:"totalAlbums"`
|
||||
TotalArtists int32 `json:"totalArtists"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TotalDuration float64 `json:"totalDuration"`
|
||||
}
|
||||
|
||||
// LibraryService provides access to music library metadata for plugins.
|
||||
//
|
||||
// This service allows plugins to query information about configured music libraries,
|
||||
// including statistics and optionally filesystem access to library directories.
|
||||
// Filesystem access is controlled via the `filesystem` permission flag.
|
||||
//
|
||||
//nd:hostservice name=Library permission=library
|
||||
type LibraryService interface {
|
||||
// GetLibrary retrieves metadata for a specific library by ID.
|
||||
//
|
||||
// Parameters:
|
||||
// - id: The library's unique identifier
|
||||
//
|
||||
// Returns the library metadata, or an error if the library is not found.
|
||||
//nd:hostfunc
|
||||
GetLibrary(ctx context.Context, id int32) (*Library, error)
|
||||
|
||||
// GetAllLibraries retrieves metadata for all configured libraries.
|
||||
//
|
||||
// Returns a slice of all libraries with their metadata.
|
||||
//nd:hostfunc
|
||||
GetAllLibraries(ctx context.Context) ([]Library, error)
|
||||
}
|
||||
118
plugins/host/library_gen.go
Normal file
118
plugins/host/library_gen.go
Normal file
@ -0,0 +1,118 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// LibraryGetLibraryRequest is the request type for Library.GetLibrary.
|
||||
type LibraryGetLibraryRequest struct {
|
||||
Id int32 `json:"id"`
|
||||
}
|
||||
|
||||
// LibraryGetLibraryResponse is the response type for Library.GetLibrary.
|
||||
type LibraryGetLibraryResponse struct {
|
||||
Result *Library `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryGetAllLibrariesResponse is the response type for Library.GetAllLibraries.
|
||||
type LibraryGetAllLibrariesResponse struct {
|
||||
Result []Library `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterLibraryHostFunctions registers Library service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterLibraryHostFunctions(service LibraryService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newLibraryGetLibraryHostFunction(service),
|
||||
newLibraryGetAllLibrariesHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newLibraryGetLibraryHostFunction(service LibraryService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"library_getlibrary",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
libraryWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req LibraryGetLibraryRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
libraryWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, svcErr := service.GetLibrary(ctx, req.Id)
|
||||
if svcErr != nil {
|
||||
libraryWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := LibraryGetLibraryResponse{
|
||||
Result: result,
|
||||
}
|
||||
libraryWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
func newLibraryGetAllLibrariesHostFunction(service LibraryService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"library_getalllibraries",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
|
||||
// Call the service method
|
||||
result, svcErr := service.GetAllLibraries(ctx)
|
||||
if svcErr != nil {
|
||||
libraryWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := LibraryGetAllLibrariesResponse{
|
||||
Result: result,
|
||||
}
|
||||
libraryWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// libraryWriteResponse writes a JSON response to plugin memory.
|
||||
func libraryWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
libraryWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// libraryWriteError writes an error response to plugin memory.
|
||||
func libraryWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
86
plugins/host/python/nd_host_library.py
Normal file
86
plugins/host/python/nd_host_library.py
Normal file
@ -0,0 +1,86 @@
|
||||
# Code generated by hostgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the Library host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "library_getlibrary")
|
||||
def _library_getlibrary(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "library_getalllibraries")
|
||||
def _library_getalllibraries(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def library_get_library(id: int) -> Any:
|
||||
"""GetLibrary retrieves metadata for a specific library by ID.
|
||||
|
||||
Parameters:
|
||||
- id: The library's unique identifier
|
||||
|
||||
Returns the library metadata, or an error if the library is not found.
|
||||
|
||||
Args:
|
||||
id: int parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"id": id,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _library_getlibrary(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)
|
||||
|
||||
|
||||
def library_get_all_libraries() -> Any:
|
||||
"""GetAllLibraries retrieves metadata for all configured libraries.
|
||||
|
||||
Returns a slice of all libraries with their metadata.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request_bytes = b"{}"
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _library_getalllibraries(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)
|
||||
72
plugins/host_library.go
Normal file
72
plugins/host_library.go
Normal file
@ -0,0 +1,72 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/plugins/host"
|
||||
)
|
||||
|
||||
type libraryServiceImpl struct {
|
||||
ds model.DataStore
|
||||
hasFilesystemPerm bool
|
||||
}
|
||||
|
||||
func newLibraryService(ds model.DataStore, perm *LibraryPermission) host.LibraryService {
|
||||
hasFS := perm != nil && perm.Filesystem
|
||||
return &libraryServiceImpl{
|
||||
ds: ds,
|
||||
hasFilesystemPerm: hasFS,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Library, error) {
|
||||
lib, err := s.ds.Library(ctx).Get(int(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("library not found: %w", err)
|
||||
}
|
||||
|
||||
return s.convertLibrary(lib), nil
|
||||
}
|
||||
|
||||
func (s *libraryServiceImpl) GetAllLibraries(ctx context.Context) ([]host.Library, error) {
|
||||
libs, err := s.ds.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get libraries: %w", err)
|
||||
}
|
||||
|
||||
result := make([]host.Library, len(libs))
|
||||
for i, lib := range libs {
|
||||
result[i] = *s.convertLibrary(&lib)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *libraryServiceImpl) convertLibrary(lib *model.Library) *host.Library {
|
||||
hostLib := &host.Library{
|
||||
ID: int32(lib.ID),
|
||||
Name: lib.Name,
|
||||
LastScanAt: lib.LastScanAt.Unix(),
|
||||
TotalSongs: int32(lib.TotalSongs),
|
||||
TotalAlbums: int32(lib.TotalAlbums),
|
||||
TotalArtists: int32(lib.TotalArtists),
|
||||
TotalSize: lib.TotalSize,
|
||||
TotalDuration: lib.TotalDuration,
|
||||
}
|
||||
|
||||
// Only include path and mount point if filesystem permission is granted
|
||||
if s.hasFilesystemPerm {
|
||||
hostLib.Path = lib.Path
|
||||
hostLib.MountPoint = toPluginMountPoint(int32(lib.ID))
|
||||
}
|
||||
|
||||
return hostLib
|
||||
}
|
||||
|
||||
func toPluginMountPoint(libID int32) string {
|
||||
return fmt.Sprintf("/libraries/%d", libID)
|
||||
}
|
||||
|
||||
var _ host.LibraryService = (*libraryServiceImpl)(nil)
|
||||
482
plugins/host_library_test.go
Normal file
482
plugins/host_library_test.go
Normal file
@ -0,0 +1,482 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("LibraryService", Ordered, func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
service *libraryServiceImpl
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ctx = context.Background()
|
||||
ds = &tests.MockDataStore{}
|
||||
})
|
||||
|
||||
Describe("GetLibrary", func() {
|
||||
It("should return library metadata without filesystem permission", func() {
|
||||
reason := "test"
|
||||
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl)
|
||||
|
||||
lib := &model.Library{
|
||||
ID: 1,
|
||||
Name: "Test Library",
|
||||
Path: "/music/test",
|
||||
TotalSongs: 100,
|
||||
TotalAlbums: 10,
|
||||
TotalArtists: 5,
|
||||
TotalSize: 1024000,
|
||||
TotalDuration: 3600.5,
|
||||
}
|
||||
lib.LastScanAt = lib.LastScanAt.Add(0) // Ensure time is set
|
||||
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(model.Libraries{*lib})
|
||||
|
||||
result, err := service.GetLibrary(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.ID).To(Equal(int32(1)))
|
||||
Expect(result.Name).To(Equal("Test Library"))
|
||||
Expect(result.TotalSongs).To(Equal(int32(100)))
|
||||
Expect(result.TotalAlbums).To(Equal(int32(10)))
|
||||
Expect(result.TotalArtists).To(Equal(int32(5)))
|
||||
Expect(result.TotalSize).To(Equal(int64(1024000)))
|
||||
Expect(result.TotalDuration).To(Equal(3600.5))
|
||||
Expect(result.Path).To(BeEmpty(), "Path should not be included without filesystem permission")
|
||||
Expect(result.MountPoint).To(BeEmpty(), "MountPoint should not be included without filesystem permission")
|
||||
})
|
||||
|
||||
It("should return library metadata with filesystem permission", func() {
|
||||
reason := "test"
|
||||
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl)
|
||||
|
||||
lib := &model.Library{
|
||||
ID: 2,
|
||||
Name: "FS Library",
|
||||
Path: "/music/fs",
|
||||
TotalSongs: 50,
|
||||
TotalAlbums: 5,
|
||||
TotalArtists: 3,
|
||||
TotalSize: 512000,
|
||||
TotalDuration: 1800.0,
|
||||
}
|
||||
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(model.Libraries{*lib})
|
||||
|
||||
result, err := service.GetLibrary(ctx, 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.ID).To(Equal(int32(2)))
|
||||
Expect(result.Name).To(Equal("FS Library"))
|
||||
Expect(result.Path).To(Equal("/music/fs"), "Path should be included with filesystem permission")
|
||||
Expect(result.MountPoint).To(Equal("/libraries/2"), "MountPoint should be included with filesystem permission")
|
||||
})
|
||||
|
||||
It("should return error for non-existent library", func() {
|
||||
reason := "test"
|
||||
service = newLibraryService(ds, &LibraryPermission{Reason: &reason}).(*libraryServiceImpl)
|
||||
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(model.Libraries{})
|
||||
|
||||
_, err := service.GetLibrary(ctx, 999)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("library not found"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAllLibraries", func() {
|
||||
It("should return all libraries without filesystem permission", func() {
|
||||
reason := "test"
|
||||
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}).(*libraryServiceImpl)
|
||||
|
||||
libs := model.Libraries{
|
||||
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
|
||||
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
|
||||
}
|
||||
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(libs)
|
||||
|
||||
results, err := service.GetAllLibraries(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(2))
|
||||
Expect(results[0].Name).To(Equal("Rock"))
|
||||
Expect(results[0].Path).To(BeEmpty())
|
||||
Expect(results[0].MountPoint).To(BeEmpty())
|
||||
Expect(results[1].Name).To(Equal("Jazz"))
|
||||
Expect(results[1].Path).To(BeEmpty())
|
||||
Expect(results[1].MountPoint).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("should return all libraries with filesystem permission", func() {
|
||||
reason := "test"
|
||||
service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}).(*libraryServiceImpl)
|
||||
|
||||
libs := model.Libraries{
|
||||
{ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100},
|
||||
{ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50},
|
||||
}
|
||||
|
||||
mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo)
|
||||
mockLibRepo.SetData(libs)
|
||||
|
||||
results, err := service.GetAllLibraries(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(2))
|
||||
Expect(results[0].Path).To(Equal("/music/rock"))
|
||||
Expect(results[0].MountPoint).To(Equal("/libraries/1"))
|
||||
Expect(results[1].Path).To(Equal("/music/jazz"))
|
||||
Expect(results[1].MountPoint).To(Equal("/libraries/2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Integration", func() {
|
||||
var (
|
||||
manager *Manager
|
||||
tmpDir string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "library-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Note: Since we don't have WASM test plugins yet, we can test
|
||||
// the service registration and configuration without full plugin execution
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Create mock &tests.MockLibraryRepo{}
|
||||
mockLibRepo := &tests.MockLibraryRepo{}
|
||||
mockLibRepo.SetData(model.Libraries{
|
||||
{ID: 1, Name: "Test", Path: "/tmp/test-music", TotalSongs: 10},
|
||||
})
|
||||
|
||||
ds := &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedPlugin: tests.CreateMockPluginRepo(),
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: ds,
|
||||
}
|
||||
|
||||
DeferCleanup(func() {
|
||||
if manager != nil {
|
||||
_ = manager.Stop()
|
||||
}
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
It("should register library service in hostServices table", func() {
|
||||
// Verify the library service is in the hostServices table
|
||||
found := false
|
||||
for _, entry := range hostServices {
|
||||
if entry.name == "Library" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Library service should be registered in hostServices")
|
||||
})
|
||||
|
||||
It("should configure AllowedPaths when filesystem permission is granted", func() {
|
||||
// This test verifies the AllowedPaths configuration logic
|
||||
// We can't fully test without a real WASM plugin, but we can verify the setup
|
||||
Expect(manager.ds).ToNot(BeNil())
|
||||
|
||||
ctx := context.Background()
|
||||
libs, err := manager.ds.Library(adminContext(ctx)).GetAll()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(libs).To(HaveLen(1))
|
||||
Expect(libs[0].Path).To(Equal("/tmp/test-music"))
|
||||
|
||||
// Verify mount point format
|
||||
mountPoint := "/libraries/1"
|
||||
Expect(mountPoint).To(MatchRegexp(`^/libraries/\d+$`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("LibraryService Integration", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
tmpDir string
|
||||
libraryDir string
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "library-integration-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create a library directory with a test file
|
||||
libraryDir = filepath.Join(tmpDir, "music-library")
|
||||
err = os.MkdirAll(libraryDir, 0755)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create a test file in the library
|
||||
testFile := filepath.Join(libraryDir, "test-track.txt")
|
||||
err = os.WriteFile(testFile, []byte("test audio file content"), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-library plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-library.wasm")
|
||||
destPath := filepath.Join(tmpDir, "test-library.wasm")
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Compute SHA256 for the plugin
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin and library
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(model.Plugins{{
|
||||
ID: "test-library",
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
}})
|
||||
|
||||
mockLibraryRepo := &tests.MockLibraryRepo{}
|
||||
mockLibraryRepo.SetData(model.Libraries{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "Test Library",
|
||||
Path: libraryDir,
|
||||
TotalSongs: 100,
|
||||
TotalAlbums: 10,
|
||||
TotalArtists: 5,
|
||||
TotalSize: 1024000,
|
||||
TotalDuration: 3600.5,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Name: "Jazz Collection",
|
||||
Path: "/nonexistent/jazz",
|
||||
TotalSongs: 50,
|
||||
TotalAlbums: 5,
|
||||
TotalArtists: 3,
|
||||
TotalSize: 512000,
|
||||
TotalDuration: 1800.0,
|
||||
},
|
||||
})
|
||||
|
||||
dataStore := &tests.MockDataStore{
|
||||
MockedPlugin: mockPluginRepo,
|
||||
MockedLibrary: mockLibraryRepo,
|
||||
}
|
||||
|
||||
// Create and start manager
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Loading", func() {
|
||||
It("should load plugin with library permission", func() {
|
||||
manager.mu.RLock()
|
||||
p, ok := manager.plugins["test-library"]
|
||||
manager.mu.RUnlock()
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(p.manifest.Permissions).ToNot(BeNil())
|
||||
Expect(p.manifest.Permissions.Library).ToNot(BeNil())
|
||||
Expect(p.manifest.Permissions.Library.Filesystem).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Library Operations via Plugin", func() {
|
||||
type testLibraryInput struct {
|
||||
Operation string `json:"operation"`
|
||||
LibraryID int32 `json:"library_id,omitempty"`
|
||||
MountPoint string `json:"mount_point,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
}
|
||||
type library struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MountPoint string `json:"mountPoint,omitempty"`
|
||||
LastScanAt int64 `json:"lastScanAt"`
|
||||
TotalSongs int32 `json:"totalSongs"`
|
||||
TotalAlbums int32 `json:"totalAlbums"`
|
||||
TotalArtists int32 `json:"totalArtists"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TotalDuration float64 `json:"totalDuration"`
|
||||
}
|
||||
type testLibraryOutput struct {
|
||||
Library *library `json:"library,omitempty"`
|
||||
Libraries []library `json:"libraries,omitempty"`
|
||||
FileContent string `json:"file_content,omitempty"`
|
||||
DirEntries []string `json:"dir_entries,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
callTestLibrary := func(ctx context.Context, input testLibraryInput) (*testLibraryOutput, error) {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-library"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
instance, err := p.instance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer instance.Close(ctx)
|
||||
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
_, outputBytes, err := instance.Call("nd_test_library", inputBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var output testLibraryOutput
|
||||
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Error != nil {
|
||||
return nil, errors.New(*output.Error)
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
It("should get library by ID with metadata", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "get_library",
|
||||
LibraryID: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Library).ToNot(BeNil())
|
||||
Expect(output.Library.ID).To(Equal(int32(1)))
|
||||
Expect(output.Library.Name).To(Equal("Test Library"))
|
||||
Expect(output.Library.TotalSongs).To(Equal(int32(100)))
|
||||
Expect(output.Library.TotalAlbums).To(Equal(int32(10)))
|
||||
Expect(output.Library.TotalArtists).To(Equal(int32(5)))
|
||||
})
|
||||
|
||||
It("should include path and mount point with filesystem permission", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "get_library",
|
||||
LibraryID: 1,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Library).ToNot(BeNil())
|
||||
Expect(output.Library.Path).To(Equal(libraryDir))
|
||||
Expect(output.Library.MountPoint).To(Equal("/libraries/1"))
|
||||
})
|
||||
|
||||
It("should get all libraries", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "get_all_libraries",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Libraries).To(HaveLen(2))
|
||||
|
||||
// First library
|
||||
Expect(output.Libraries[0].ID).To(Equal(int32(1)))
|
||||
Expect(output.Libraries[0].Name).To(Equal("Test Library"))
|
||||
Expect(output.Libraries[0].MountPoint).To(Equal("/libraries/1"))
|
||||
|
||||
// Second library
|
||||
Expect(output.Libraries[1].ID).To(Equal(int32(2)))
|
||||
Expect(output.Libraries[1].Name).To(Equal("Jazz Collection"))
|
||||
Expect(output.Libraries[1].MountPoint).To(Equal("/libraries/2"))
|
||||
})
|
||||
|
||||
It("should return error for non-existent library", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
_, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "get_library",
|
||||
LibraryID: 999,
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("library not found"))
|
||||
})
|
||||
|
||||
It("should read file from mounted library directory", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "read_file",
|
||||
MountPoint: "/libraries/1",
|
||||
FilePath: "test-track.txt",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.FileContent).To(Equal("test audio file content"))
|
||||
})
|
||||
|
||||
It("should list files in mounted library directory", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
output, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "list_dir",
|
||||
MountPoint: "/libraries/1",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.DirEntries).To(ContainElement("test-track.txt"))
|
||||
})
|
||||
|
||||
It("should fail to access unmapped library directory", func() {
|
||||
ctx := GinkgoT().Context()
|
||||
|
||||
// Try to access a path outside the mapped libraries
|
||||
_, err := callTestLibrary(ctx, testLibraryInput{
|
||||
Operation: "list_dir",
|
||||
MountPoint: "/etc",
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -85,6 +85,16 @@ var hostServices = []hostServiceEntry{
|
||||
return host.RegisterCacheHostFunctions(service), service
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Library",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
|
||||
registerStubs: func() []extism.HostFunction { return host.RegisterLibraryHostFunctions(nil) },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Library
|
||||
service := newLibraryService(ctx.manager.ds, perm)
|
||||
return host.RegisterLibraryHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// stubHostFunctions returns the list of stub host functions needed for initial plugin compilation.
|
||||
@ -314,6 +324,21 @@ func (m *Manager) loadPluginWithConfig(name, wasmPath, configJSON string) error
|
||||
pluginManifest.AllowedHosts = hosts
|
||||
}
|
||||
|
||||
// Configure filesystem access for library permission
|
||||
if info.manifest.Permissions != nil && info.manifest.Permissions.Library != nil && info.manifest.Permissions.Library.Filesystem {
|
||||
adminCtx := adminContext(m.ctx)
|
||||
libraries, err := m.ds.Library(adminCtx).GetAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
|
||||
}
|
||||
|
||||
allowedPaths := make(map[string]string)
|
||||
for _, lib := range libraries {
|
||||
allowedPaths[lib.Path] = toPluginMountPoint(int32(lib.ID))
|
||||
}
|
||||
pluginManifest.AllowedPaths = allowedPaths
|
||||
}
|
||||
|
||||
// Register host functions based on permissions using table-driven approach
|
||||
svcCtx := &serviceContext{
|
||||
pluginName: name,
|
||||
|
||||
@ -58,6 +58,9 @@
|
||||
},
|
||||
"cache": {
|
||||
"$ref": "#/$defs/CachePermission"
|
||||
},
|
||||
"library": {
|
||||
"$ref": "#/$defs/LibraryPermission"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -163,6 +166,22 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"LibraryPermission": {
|
||||
"type": "object",
|
||||
"description": "Library service permissions for accessing library metadata and optionally filesystem",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why library access is needed"
|
||||
},
|
||||
"filesystem": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the plugin requires read-only filesystem access to library directories",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,6 +33,34 @@ type HTTPPermission struct {
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Library service permissions for accessing library metadata and optionally
|
||||
// filesystem
|
||||
type LibraryPermission struct {
|
||||
// Whether the plugin requires read-only filesystem access to library directories
|
||||
Filesystem bool `json:"filesystem,omitempty" yaml:"filesystem,omitempty" mapstructure:"filesystem,omitempty"`
|
||||
|
||||
// Explanation for why library access is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *LibraryPermission) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
type Plain LibraryPermission
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := raw["filesystem"]; !ok || v == nil {
|
||||
plain.Filesystem = false
|
||||
}
|
||||
*j = LibraryPermission(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Plugin manifest for Navidrome plugins
|
||||
type Manifest struct {
|
||||
// The author of the plugin
|
||||
@ -98,6 +126,9 @@ type Permissions struct {
|
||||
// Http corresponds to the JSON schema field "http".
|
||||
Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"`
|
||||
|
||||
// Library corresponds to the JSON schema field "library".
|
||||
Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"`
|
||||
|
||||
// Scheduler corresponds to the JSON schema field "scheduler".
|
||||
Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"`
|
||||
|
||||
|
||||
5
plugins/testdata/test-library/go.mod
vendored
Normal file
5
plugins/testdata/test-library/go.mod
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
module test-library
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/testdata/test-library/go.sum
vendored
Normal file
2
plugins/testdata/test-library/go.sum
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
139
plugins/testdata/test-library/main.go
vendored
Normal file
139
plugins/testdata/test-library/main.go
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
// Test Library plugin for Navidrome plugin system integration tests.
|
||||
// This plugin tests library metadata access WITH filesystem permission,
|
||||
// allowing tests for both metadata and filesystem access.
|
||||
// Build with: tinygo build -o ../test-library.wasm -target wasip1 -buildmode=c-shared .
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
pdk "github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Manifest types
|
||||
type Manifest struct {
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Permissions *Permissions `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type Permissions struct {
|
||||
Library *LibraryPermission `json:"library,omitempty"`
|
||||
}
|
||||
|
||||
type LibraryPermission struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Filesystem bool `json:"filesystem,omitempty"`
|
||||
}
|
||||
|
||||
//go:wasmexport nd_manifest
|
||||
func ndManifest() int32 {
|
||||
manifest := Manifest{
|
||||
Name: "Test Library Plugin",
|
||||
Author: "Navidrome Test",
|
||||
Version: "1.0.0",
|
||||
Description: "A test library plugin for integration testing",
|
||||
Permissions: &Permissions{
|
||||
Library: &LibraryPermission{
|
||||
Reason: "For testing library metadata and filesystem access",
|
||||
Filesystem: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
out, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
pdk.SetError(err)
|
||||
return 1
|
||||
}
|
||||
pdk.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// TestLibraryInput is the input for nd_test_library callback.
|
||||
type TestLibraryInput struct {
|
||||
Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir"
|
||||
LibraryID int32 `json:"library_id,omitempty"`
|
||||
MountPoint string `json:"mount_point,omitempty"` // For filesystem operations
|
||||
FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point)
|
||||
}
|
||||
|
||||
// TestLibraryOutput is the output from nd_test_library callback.
|
||||
type TestLibraryOutput struct {
|
||||
Library *Library `json:"library,omitempty"`
|
||||
Libraries []Library `json:"libraries,omitempty"`
|
||||
FileContent string `json:"file_content,omitempty"`
|
||||
DirEntries []string `json:"dir_entries,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// nd_test_library is the test callback that tests the library host functions.
|
||||
//
|
||||
//go:wasmexport nd_test_library
|
||||
func ndTestLibrary() int32 {
|
||||
var input TestLibraryInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
|
||||
switch input.Operation {
|
||||
case "get_library":
|
||||
resp, err := LibraryGetLibrary(input.LibraryID)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestLibraryOutput{Library: resp.Result})
|
||||
return 0
|
||||
|
||||
case "get_all_libraries":
|
||||
resp, err := LibraryGetAllLibraries()
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestLibraryOutput{Libraries: resp.Result})
|
||||
return 0
|
||||
|
||||
case "read_file":
|
||||
// Read a file from the mounted library directory
|
||||
fullPath := filepath.Join(input.MountPoint, input.FilePath)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
pdk.OutputJSON(TestLibraryOutput{FileContent: string(content)})
|
||||
return 0
|
||||
|
||||
case "list_dir":
|
||||
// List files in the mounted library directory
|
||||
entries, err := os.ReadDir(input.MountPoint)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
pdk.OutputJSON(TestLibraryOutput{DirEntries: names})
|
||||
return 0
|
||||
|
||||
default:
|
||||
errStr := "unknown operation: " + input.Operation
|
||||
pdk.OutputJSON(TestLibraryOutput{Error: &errStr})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func main() {}
|
||||
127
plugins/testdata/test-library/nd_host_library.go
vendored
Normal file
127
plugins/testdata/test-library/nd_host_library.go
vendored
Normal file
@ -0,0 +1,127 @@
|
||||
// Code generated by hostgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Library host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
// Library represents a music library with metadata.
|
||||
// This type mirrors the host.Library type from plugins/host/library.go.
|
||||
type Library struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MountPoint string `json:"mountPoint,omitempty"`
|
||||
LastScanAt int64 `json:"lastScanAt"`
|
||||
TotalSongs int32 `json:"totalSongs"`
|
||||
TotalAlbums int32 `json:"totalAlbums"`
|
||||
TotalArtists int32 `json:"totalArtists"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
TotalDuration float64 `json:"totalDuration"`
|
||||
}
|
||||
|
||||
// library_getlibrary is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user library_getlibrary
|
||||
func library_getlibrary(uint64) uint64
|
||||
|
||||
// library_getalllibraries is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user library_getalllibraries
|
||||
func library_getalllibraries(uint64) uint64
|
||||
|
||||
// LibraryGetLibraryRequest is the request type for Library.GetLibrary.
|
||||
type LibraryGetLibraryRequest struct {
|
||||
Id int32 `json:"id"`
|
||||
}
|
||||
|
||||
// LibraryGetLibraryResponse is the response type for Library.GetLibrary.
|
||||
type LibraryGetLibraryResponse struct {
|
||||
Result *Library `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryGetAllLibrariesResponse is the response type for Library.GetAllLibraries.
|
||||
type LibraryGetAllLibrariesResponse struct {
|
||||
Result []Library `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryGetLibrary calls the library_getlibrary host function.
|
||||
// GetLibrary retrieves metadata for a specific library by ID.
|
||||
//
|
||||
// Parameters:
|
||||
// - id: The library's unique identifier
|
||||
//
|
||||
// Returns the library metadata, or an error if the library is not found.
|
||||
func LibraryGetLibrary(id int32) (*LibraryGetLibraryResponse, error) {
|
||||
// Marshal request to JSON
|
||||
req := LibraryGetLibraryRequest{
|
||||
Id: id,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := library_getlibrary(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response LibraryGetLibraryResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// LibraryGetAllLibraries calls the library_getalllibraries host function.
|
||||
// GetAllLibraries retrieves metadata for all configured libraries.
|
||||
//
|
||||
// Returns a slice of all libraries with their metadata.
|
||||
func LibraryGetAllLibraries() (*LibraryGetAllLibrariesResponse, error) {
|
||||
// No parameters - allocate empty JSON object
|
||||
reqMem := pdk.AllocateBytes([]byte("{}"))
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := library_getalllibraries(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response LibraryGetAllLibrariesResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user