From 1ad824c724aac1d945fe6c1f668b0b218c100c32 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 28 Dec 2025 15:20:44 -0500 Subject: [PATCH] feat(library): add Library service for metadata access and filesystem integration Signed-off-by: Deluan --- plugins/README.md | 97 +++- plugins/host/go/nd_host_library.go | 112 ++++ plugins/host/library.go | 41 ++ plugins/host/library_gen.go | 118 +++++ plugins/host/python/nd_host_library.py | 86 ++++ plugins/host_library.go | 72 +++ plugins/host_library_test.go | 482 ++++++++++++++++++ plugins/manager_loader.go | 25 + plugins/manifest.json | 19 + plugins/manifest_gen.go | 31 ++ plugins/testdata/test-library/go.mod | 5 + plugins/testdata/test-library/go.sum | 2 + plugins/testdata/test-library/main.go | 139 +++++ .../testdata/test-library/nd_host_library.go | 127 +++++ 14 files changed, 1355 insertions(+), 1 deletion(-) create mode 100644 plugins/host/go/nd_host_library.go create mode 100644 plugins/host/library.go create mode 100644 plugins/host/library_gen.go create mode 100644 plugins/host/python/nd_host_library.py create mode 100644 plugins/host_library.go create mode 100644 plugins/host_library_test.go create mode 100644 plugins/testdata/test-library/go.mod create mode 100644 plugins/testdata/test-library/go.sum create mode 100644 plugins/testdata/test-library/main.go create mode 100644 plugins/testdata/test-library/nd_host_library.go diff --git a/plugins/README.md b/plugins/README.md index 32ecbf3e1..b6611d1f4 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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/`: + +```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 diff --git a/plugins/host/go/nd_host_library.go b/plugins/host/go/nd_host_library.go new file mode 100644 index 000000000..6c6daaf70 --- /dev/null +++ b/plugins/host/go/nd_host_library.go @@ -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 +} diff --git a/plugins/host/library.go b/plugins/host/library.go new file mode 100644 index 000000000..ed86b8b3d --- /dev/null +++ b/plugins/host/library.go @@ -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) +} diff --git a/plugins/host/library_gen.go b/plugins/host/library_gen.go new file mode 100644 index 000000000..95409ab36 --- /dev/null +++ b/plugins/host/library_gen.go @@ -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 +} diff --git a/plugins/host/python/nd_host_library.py b/plugins/host/python/nd_host_library.py new file mode 100644 index 000000000..4111df2b9 --- /dev/null +++ b/plugins/host/python/nd_host_library.py @@ -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) diff --git a/plugins/host_library.go b/plugins/host_library.go new file mode 100644 index 000000000..62eb5d647 --- /dev/null +++ b/plugins/host_library.go @@ -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) diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go new file mode 100644 index 000000000..d7375734b --- /dev/null +++ b/plugins/host_library_test.go @@ -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()) + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 1aca9fdc8..fe769ee7c 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -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, diff --git a/plugins/manifest.json b/plugins/manifest.json index 9208edd87..7e0fc9a85 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -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 + } + } } } } diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 812c20375..a7d4df873 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -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"` diff --git a/plugins/testdata/test-library/go.mod b/plugins/testdata/test-library/go.mod new file mode 100644 index 000000000..7a91b13ab --- /dev/null +++ b/plugins/testdata/test-library/go.mod @@ -0,0 +1,5 @@ +module test-library + +go 1.23 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/testdata/test-library/go.sum b/plugins/testdata/test-library/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/plugins/testdata/test-library/go.sum @@ -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= diff --git a/plugins/testdata/test-library/main.go b/plugins/testdata/test-library/main.go new file mode 100644 index 000000000..f3cbc44e8 --- /dev/null +++ b/plugins/testdata/test-library/main.go @@ -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() {} diff --git a/plugins/testdata/test-library/nd_host_library.go b/plugins/testdata/test-library/nd_host_library.go new file mode 100644 index 000000000..389721dc3 --- /dev/null +++ b/plugins/testdata/test-library/nd_host_library.go @@ -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 +}