Skip to content

Commit 7bd6f02

Browse files
authored
Merge pull request #118 from contre95/fix/plugin_autolaod
fix(plugins): Load plugins from repo's url
2 parents a3d55af + 80d268e commit 7bd6f02

File tree

6 files changed

+238
-13
lines changed

6 files changed

+238
-13
lines changed

README.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,21 @@
66
</table>
77
88
[![Join Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/mHRjGAjEJz)
9-
[See Demo](https://soulsolid-demo.contre.io/)
109

11-
A feature rich music organization app built for the music hoarder. Heavily under development, focused on ease of usage.
10+
A work in progress, feature rich music organization app built for the music hoarder. Heavily under development, focused on ease of usage and start up. Feel free to check the [docs](https://soulsolid.contre.io) or [demo](https://soulsolid-demo.contre.io)
11+
12+
## Screenshots
13+
<table>
14+
<tr>
15+
<td>
16+
<img src="./docs/screen0.jpg" />
17+
</td>
18+
<td>
19+
<img src="./docs/screen1.jpg" />
20+
</td>
21+
</tr>
22+
</table>
23+
1224
## Features
1325
- **Music Library Management**: Organize and browse albums, artists, and tracks
1426
- **Downloading**: Download tracks and albums.
@@ -20,6 +32,8 @@ A feature rich music organization app built for the music hoarder. Heavily under
2032
- **Job Management**: Background processing for downloads, imports, and syncs
2133

2234
Documentation: https://soulsolid.contre.io
35+
Demo: https://soulsolid-demo.contre.io
36+
2337

2438
## Quick Start
2539

@@ -52,7 +66,7 @@ podman run -d \
5266
-p 3535:3535 \
5367
-v /host/music:/app/library \
5468
-v /host/downloads:/app/downloads \
55-
-v /host/logs:/app/logs \
69+
-v /host/logs:/app/logs \ # optional
5670
-v /host/library.db:/data/library.db \
5771
-v /host/config.yaml:/config/config.yaml \
5872
soulsolid
@@ -92,6 +106,4 @@ nix-shell dev.nix
92106
# Then, simply run:
93107
go run ./src/main.go
94108
```
95-
96109
The web interface will be available at `http://localhost:3535`.
97-

docs/plugins.md

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ Example config.yaml:
136136
downloaders:
137137
plugins:
138138
- name: "mydownloader"
139-
path: "/path/to/mydownloader.so"
139+
# Either specify a URL to build from git:
140+
url: "https://github.com/user/soulsolid-myplugin.git"
141+
# Or specify a path to a pre-built .so file:
142+
# path: "/path/to/mydownloader.so"
140143
icon: "path/to/icon.png" # Optional icon for the downloader
141144
config:
142145
api_key: "your_api_key_here"
@@ -146,11 +149,55 @@ downloaders:
146149
147150
## Distribution
148151
149-
1. **Build for the target platform:** Make sure to build the plugin for the same OS and architecture as the Soulsolid binary.
152+
There are two ways to distribute plugins:
150153
154+
### Option 1: Pre-built .so files
155+
1. **Build for the target platform:** Make sure to build the plugin for the same OS and architecture as the Soulsolid binary.
151156
2. **Distribute the .so file:** Users can place the `.so` file anywhere accessible to Soulsolid and configure the path in their config.
152157

153-
3. **Version compatibility:** Plugins should be built against the same version of Soulsolid to ensure API compatibility.
158+
### Option 2: Git repositories
159+
1. **Publish your plugin source code** in a public git repository with a valid `go.mod` file.
160+
2. **Users configure the repository URL** in their Soulsolid config (see "Building from Git Repositories" section below).
161+
162+
### Version compatibility
163+
Plugins should be built against the same version of Soulsolid to ensure API compatibility.
164+
165+
## Building from Git Repositories
166+
167+
Soulsolid can automatically build plugins directly from git repositories. Instead of distributing pre-built `.so` files, you can specify a repository URL in your configuration.
168+
169+
### Configuration Example
170+
171+
```yaml
172+
downloaders:
173+
plugins:
174+
- name: "mydownloader"
175+
url: "https://github.com/user/soulsolid-myplugin.git"
176+
# path: "/fallback.so" # Optional fallback path if URL building fails
177+
config:
178+
api_key: "your_api_key_here"
179+
```
180+
181+
### Requirements
182+
183+
- **git** must be installed and available in PATH
184+
- **go** must be installed (same version as the main application) and available in PATH
185+
- Network access to clone the repository
186+
187+
### How It Works
188+
189+
1. Soulsolid clones the repository (default branch only, depth=1)
190+
2. Adds a replace directive to use the local soulsolid module
191+
3. Runs `go mod tidy` to resolve dependencies
192+
4. Builds the plugin with `go build -buildmode=plugin`
193+
5. Loads the built `.so` file into memory
194+
195+
### Notes
196+
197+
- Plugins are rebuilt each time Soulsolid starts (no caching)
198+
- Only the default branch is cloned (no branch/tag/commit support)
199+
- Repository must contain a `go.mod` file at the root
200+
- Building arbitrary code carries inherent security risks (same as loading pre-built `.so` files)
154201

155202
## Best Practices
156203

docs/screen0.jpg

137 KB
Loading

docs/screen1.jpg

145 KB
Loading

src/features/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ type EmbeddedArtwork struct {
127127
type PluginConfig struct {
128128
Name string `yaml:"name"`
129129
Path string `yaml:"path"`
130+
URL string `yaml:"url,omitempty"`
130131
Icon string `yaml:"icon,omitempty"`
131132
Config map[string]any `yaml:"config"`
132133
}

src/features/downloading/plugins.go

Lines changed: 170 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
package downloading
22

33
import (
4+
"bytes"
5+
"context"
46
"fmt"
57
"io"
68
"log/slog"
79
"net/http"
810
"os"
11+
"os/exec"
12+
"path/filepath"
913
"plugin"
1014
"strings"
1115
"sync"
16+
"time"
1217

1318
"github.com/contre95/soulsolid/src/features/config"
1419
)
@@ -22,6 +27,139 @@ type PluginManager struct {
2227
mu sync.RWMutex
2328
}
2429

30+
// findModuleRoot finds the root directory of the current Go module by looking for go.mod
31+
func findModuleRoot() (string, error) {
32+
dir, err := os.Getwd()
33+
if err != nil {
34+
return "", fmt.Errorf("failed to get working directory: %w", err)
35+
}
36+
37+
for {
38+
goModPath := filepath.Join(dir, "go.mod")
39+
if _, err := os.Stat(goModPath); err == nil {
40+
return dir, nil
41+
}
42+
43+
parent := filepath.Dir(dir)
44+
if parent == dir {
45+
break // reached root
46+
}
47+
dir = parent
48+
}
49+
50+
return "", fmt.Errorf("go.mod not found in current or parent directories")
51+
}
52+
53+
// buildFromGit builds a plugin from a git repository URL
54+
func buildFromGit(url string) (string, error) {
55+
// Create temporary directory for cloning and building
56+
tempDir, err := os.MkdirTemp("", "soulsolid-plugin-*")
57+
if err != nil {
58+
return "", fmt.Errorf("failed to create temp directory: %w", err)
59+
}
60+
61+
// Clean up temp directory on error
62+
var buildErr error
63+
defer func() {
64+
if buildErr != nil {
65+
os.RemoveAll(tempDir)
66+
}
67+
}()
68+
69+
slog.Debug("Cloning git repository", "url", url, "tempDir", tempDir)
70+
71+
// Clone repository
72+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
73+
defer cancel()
74+
75+
cmd := exec.CommandContext(ctx, "git", "clone", "--depth", "1", url, tempDir)
76+
var stderr bytes.Buffer
77+
cmd.Stderr = &stderr
78+
if err := cmd.Run(); err != nil {
79+
buildErr = fmt.Errorf("failed to clone repository: %w: %s", err, stderr.String())
80+
return "", buildErr
81+
}
82+
83+
// Find module root of soulsolid
84+
moduleRoot, err := findModuleRoot()
85+
if err != nil {
86+
buildErr = fmt.Errorf("failed to find soulsolid module root: %w", err)
87+
return "", buildErr
88+
}
89+
90+
pluginDir := tempDir
91+
92+
// Check if go.mod exists in plugin directory
93+
goModPath := filepath.Join(pluginDir, "go.mod")
94+
if _, err := os.Stat(goModPath); err != nil {
95+
buildErr = fmt.Errorf("plugin directory does not contain go.mod: %w", err)
96+
return "", buildErr
97+
}
98+
99+
// Add replace directive for soulsolid module
100+
cmd = exec.Command("go", "mod", "edit", "-replace=github.com/contre95/soulsolid="+moduleRoot)
101+
cmd.Dir = pluginDir
102+
if output, err := cmd.CombinedOutput(); err != nil {
103+
buildErr = fmt.Errorf("failed to add replace directive: %w: %s", err, output)
104+
return "", buildErr
105+
}
106+
107+
// Run go mod tidy
108+
cmd = exec.Command("go", "mod", "tidy")
109+
cmd.Dir = pluginDir
110+
if output, err := cmd.CombinedOutput(); err != nil {
111+
buildErr = fmt.Errorf("failed to run go mod tidy: %w: %s", err, output)
112+
return "", buildErr
113+
}
114+
115+
// Build the plugin
116+
outputFile := filepath.Join(tempDir, "plugin.so")
117+
cmd = exec.Command("go", "build", "-buildmode=plugin", "-o", outputFile, ".")
118+
cmd.Dir = pluginDir
119+
if output, err := cmd.CombinedOutput(); err != nil {
120+
buildErr = fmt.Errorf("failed to build plugin: %w: %s", err, output)
121+
return "", buildErr
122+
}
123+
124+
slog.Debug("Successfully built plugin from git", "url", url, "output", outputFile)
125+
126+
// Move .so file to a new temporary file (outside the source directory)
127+
// so we can clean up the source directory
128+
pluginSo, err := os.CreateTemp("", "*.so")
129+
if err != nil {
130+
buildErr = fmt.Errorf("failed to create temp .so file: %w", err)
131+
return "", buildErr
132+
}
133+
pluginSo.Close()
134+
135+
if err := os.Rename(outputFile, pluginSo.Name()); err != nil {
136+
// If rename fails (cross-device), copy instead
137+
src, err := os.Open(outputFile)
138+
if err != nil {
139+
buildErr = fmt.Errorf("failed to open built plugin: %w", err)
140+
return "", buildErr
141+
}
142+
defer src.Close()
143+
144+
dst, err := os.Create(pluginSo.Name())
145+
if err != nil {
146+
buildErr = fmt.Errorf("failed to create destination .so file: %w", err)
147+
return "", buildErr
148+
}
149+
defer dst.Close()
150+
151+
if _, err := io.Copy(dst, src); err != nil {
152+
buildErr = fmt.Errorf("failed to copy built plugin: %w", err)
153+
return "", buildErr
154+
}
155+
}
156+
157+
// Clean up source directory
158+
os.RemoveAll(tempDir)
159+
160+
return pluginSo.Name(), nil
161+
}
162+
25163
// NewPluginManager creates a new plugin manager
26164
func NewPluginManager() *PluginManager {
27165
return &PluginManager{
@@ -49,12 +187,24 @@ func (pm *PluginManager) LoadPlugins(cfg *config.Config) error {
49187

50188
// loadPlugin loads a single plugin
51189
func (pm *PluginManager) loadPlugin(pluginCfg config.PluginConfig) error {
52-
slog.Debug("Loading plugin", "name", pluginCfg.Name, "path", pluginCfg.Path)
190+
slog.Debug("Loading plugin", "name", pluginCfg.Name, "path", pluginCfg.Path, "url", pluginCfg.URL)
53191

54192
pluginPath := pluginCfg.Path
193+
isTempFile := false
194+
var tempFilePath string
55195

56-
// If path is a URL, download the plugin to a temporary file
57-
if strings.HasPrefix(pluginCfg.Path, "http://") || strings.HasPrefix(pluginCfg.Path, "https://") {
196+
// If URL is specified, build plugin from git repository
197+
if pluginCfg.URL != "" {
198+
slog.Info("Building plugin from git repository", "name", pluginCfg.Name, "url", pluginCfg.URL)
199+
builtPath, err := buildFromGit(pluginCfg.URL)
200+
if err != nil {
201+
return fmt.Errorf("failed to build plugin %s from git: %w", pluginCfg.Name, err)
202+
}
203+
pluginPath = builtPath
204+
isTempFile = true
205+
tempFilePath = builtPath
206+
} else if strings.HasPrefix(pluginCfg.Path, "http://") || strings.HasPrefix(pluginCfg.Path, "https://") {
207+
// If path is a URL, download the plugin to a temporary file
58208
tempFile, err := os.CreateTemp("", "*.so")
59209
if err != nil {
60210
return fmt.Errorf("failed to create temp file for plugin %s: %w", pluginCfg.Name, err)
@@ -81,28 +231,43 @@ func (pm *PluginManager) loadPlugin(pluginCfg config.PluginConfig) error {
81231

82232
tempFile.Close() // close before opening as plugin
83233
pluginPath = tempFile.Name()
234+
isTempFile = true
235+
tempFilePath = tempFile.Name()
84236
}
85237

86238
p, err := plugin.Open(pluginPath)
87239
if err != nil {
88-
if strings.HasPrefix(pluginCfg.Path, "http://") || strings.HasPrefix(pluginCfg.Path, "https://") {
89-
os.Remove(pluginPath) // cleanup temp file on error
240+
// Clean up temporary files if we created them
241+
if isTempFile {
242+
os.Remove(tempFilePath)
90243
}
91244
return fmt.Errorf("failed to open plugin %s: %w", pluginCfg.Path, err)
92245
}
93246

94247
sym, err := p.Lookup("NewDownloader")
95248
if err != nil {
249+
// Clean up temporary files if we created them
250+
if isTempFile {
251+
os.Remove(tempFilePath)
252+
}
96253
return fmt.Errorf("plugin %s does not export NewDownloader function: %w", pluginCfg.Name, err)
97254
}
98255

99256
newDownloaderFunc, ok := sym.(func(map[string]interface{}) (Downloader, error))
100257
if !ok {
258+
// Clean up temporary files if we created them
259+
if isTempFile {
260+
os.Remove(tempFilePath)
261+
}
101262
return fmt.Errorf("plugin %s NewDownloader function has incorrect signature", pluginCfg.Name)
102263
}
103264

104265
downloader, err := newDownloaderFunc(pluginCfg.Config)
105266
if err != nil {
267+
// Clean up temporary files if we created them
268+
if isTempFile {
269+
os.Remove(tempFilePath)
270+
}
106271
return fmt.Errorf("failed to create downloader from plugin %s: %w", pluginCfg.Name, err)
107272
}
108273

0 commit comments

Comments
 (0)