feat(library): add Library service for metadata access and filesystem integration

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-28 15:20:44 -05:00
parent aa83d3af6c
commit 1ad824c724
14 changed files with 1355 additions and 1 deletions

View File

@ -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

View 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
View 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
View 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
}

View 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
View 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)

View 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())
})
})
})

View File

@ -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,

View File

@ -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
}
}
}
}
}

View File

@ -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
View 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
View 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
View 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() {}

View 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
}