mirror of
https://github.com/navidrome/navidrome.git
synced 2025-07-13 23:21:21 +03:00
* chore: .gitignore any navidrome binary Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement internal authentication handling in middleware Signed-off-by: Deluan <deluan@navidrome.org> * feat(manager): add SubsonicRouter to Manager for API routing Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): add SubsonicAPI Host service for plugins and an example plugin Signed-off-by: Deluan <deluan@navidrome.org> * fix lint Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): refactor path handling in SubsonicAPI to extract endpoint correctly Signed-off-by: Deluan <deluan@navidrome.org> * docs(plugins): add SubsonicAPI service documentation to README Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): implement permission checks for SubsonicAPI service Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): enhance SubsonicAPI service initialization with atomic router handling Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): better encapsulated dependency injection Signed-off-by: Deluan <deluan@navidrome.org> * refactor(plugins): rename parameter in WithInternalAuth for clarity Signed-off-by: Deluan <deluan@navidrome.org> * docs(plugins): update SubsonicAPI permissions section in README for clarity and detail Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): enhance SubsonicAPI permissions output with allowed usernames and admin flag Signed-off-by: Deluan <deluan@navidrome.org> * feat(plugins): add schema reference to example plugins Signed-off-by: Deluan <deluan@navidrome.org> * remove import alias Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
717 lines
21 KiB
Go
717 lines
21 KiB
Go
package cmd
|
|
|
|
import (
|
|
"cmp"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/plugins"
|
|
"github.com/navidrome/navidrome/plugins/schema"
|
|
"github.com/navidrome/navidrome/utils"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
const (
|
|
pluginPackageExtension = ".ndp"
|
|
pluginDirPermissions = 0700
|
|
pluginFilePermissions = 0600
|
|
)
|
|
|
|
func init() {
|
|
pluginCmd := &cobra.Command{
|
|
Use: "plugin",
|
|
Short: "Manage Navidrome plugins",
|
|
Long: "Commands for managing Navidrome plugins",
|
|
}
|
|
|
|
listCmd := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List installed plugins",
|
|
Long: "List all installed plugins with their metadata",
|
|
Run: pluginList,
|
|
}
|
|
|
|
infoCmd := &cobra.Command{
|
|
Use: "info [pluginPackage|pluginName]",
|
|
Short: "Show details of a plugin",
|
|
Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: pluginInfo,
|
|
}
|
|
|
|
installCmd := &cobra.Command{
|
|
Use: "install [pluginPackage]",
|
|
Short: "Install a plugin from a .ndp file",
|
|
Long: "Install a Navidrome Plugin Package (.ndp) file",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: pluginInstall,
|
|
}
|
|
|
|
removeCmd := &cobra.Command{
|
|
Use: "remove [pluginName]",
|
|
Short: "Remove an installed plugin",
|
|
Long: "Remove a plugin by name",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: pluginRemove,
|
|
}
|
|
|
|
updateCmd := &cobra.Command{
|
|
Use: "update [pluginPackage]",
|
|
Short: "Update an existing plugin",
|
|
Long: "Update an installed plugin with a new version from a .ndp file",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: pluginUpdate,
|
|
}
|
|
|
|
refreshCmd := &cobra.Command{
|
|
Use: "refresh [pluginName]",
|
|
Short: "Reload a plugin without restarting Navidrome",
|
|
Long: "Reload and recompile a plugin without needing to restart Navidrome",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: pluginRefresh,
|
|
}
|
|
|
|
devCmd := &cobra.Command{
|
|
Use: "dev [folder_path]",
|
|
Short: "Create symlink to development folder",
|
|
Long: "Create a symlink from a plugin development folder to the plugins directory for easier development",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: pluginDev,
|
|
}
|
|
|
|
pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd)
|
|
rootCmd.AddCommand(pluginCmd)
|
|
}
|
|
|
|
// Validation helpers
|
|
|
|
func validatePluginPackageFile(path string) error {
|
|
if !utils.FileExists(path) {
|
|
return fmt.Errorf("plugin package not found: %s", path)
|
|
}
|
|
if filepath.Ext(path) != pluginPackageExtension {
|
|
return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePluginDirectory(pluginsDir, pluginName string) (string, error) {
|
|
pluginDir := filepath.Join(pluginsDir, pluginName)
|
|
if !utils.FileExists(pluginDir) {
|
|
return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir)
|
|
}
|
|
return pluginDir, nil
|
|
}
|
|
|
|
func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) {
|
|
// Check if it's a directory or a symlink
|
|
lstat, err := os.Lstat(pluginDir)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("failed to stat plugin: %w", err)
|
|
}
|
|
|
|
isSymlink = lstat.Mode()&os.ModeSymlink != 0
|
|
|
|
if isSymlink {
|
|
// Resolve the symlink target
|
|
targetDir, err := os.Readlink(pluginDir)
|
|
if err != nil {
|
|
return "", true, fmt.Errorf("failed to resolve symlink: %w", err)
|
|
}
|
|
|
|
// If target is a relative path, make it absolute
|
|
if !filepath.IsAbs(targetDir) {
|
|
targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir)
|
|
}
|
|
|
|
// Verify the target exists and is a directory
|
|
targetInfo, err := os.Stat(targetDir)
|
|
if err != nil {
|
|
return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err)
|
|
}
|
|
|
|
if !targetInfo.IsDir() {
|
|
return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir)
|
|
}
|
|
|
|
return targetDir, true, nil
|
|
} else if !lstat.IsDir() {
|
|
return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir)
|
|
}
|
|
|
|
return pluginDir, false, nil
|
|
}
|
|
|
|
// Package handling helpers
|
|
|
|
func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) {
|
|
if err := validatePluginPackageFile(ndpPath); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pkg, err := plugins.LoadPackage(ndpPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load plugin package: %w", err)
|
|
}
|
|
|
|
return pkg, nil
|
|
}
|
|
|
|
func extractAndSetupPlugin(ndpPath, targetDir string) error {
|
|
if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil {
|
|
return fmt.Errorf("failed to extract plugin package: %w", err)
|
|
}
|
|
|
|
ensurePluginDirPermissions(targetDir)
|
|
return nil
|
|
}
|
|
|
|
// Display helpers
|
|
|
|
func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) {
|
|
if discovery.Error != nil {
|
|
// Handle global errors (like directory read failure)
|
|
if discovery.ID == "" {
|
|
log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error)
|
|
return
|
|
}
|
|
// Handle individual plugin errors - show them in the table
|
|
fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error)
|
|
return
|
|
}
|
|
|
|
// Mark symlinks with an indicator
|
|
nameDisplay := discovery.Manifest.Name
|
|
if discovery.IsSymlink {
|
|
nameDisplay = nameDisplay + " (dev)"
|
|
}
|
|
|
|
// Convert capabilities to strings
|
|
capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string {
|
|
return string(cap)
|
|
})
|
|
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
|
|
discovery.ID,
|
|
nameDisplay,
|
|
cmp.Or(discovery.Manifest.Author, "-"),
|
|
cmp.Or(discovery.Manifest.Version, "-"),
|
|
strings.Join(capabilities, ", "),
|
|
cmp.Or(discovery.Manifest.Description, "-"))
|
|
}
|
|
|
|
func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) {
|
|
if permissions.Http != nil {
|
|
fmt.Printf("%shttp:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason)
|
|
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork)
|
|
fmt.Printf("%s Allowed URLs:\n", indent)
|
|
for urlPattern, methodEnums := range permissions.Http.AllowedUrls {
|
|
methods := make([]string, len(methodEnums))
|
|
for i, methodEnum := range methodEnums {
|
|
methods[i] = string(methodEnum)
|
|
}
|
|
fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", "))
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
if permissions.Config != nil {
|
|
fmt.Printf("%sconfig:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason)
|
|
fmt.Println()
|
|
}
|
|
|
|
if permissions.Scheduler != nil {
|
|
fmt.Printf("%sscheduler:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason)
|
|
fmt.Println()
|
|
}
|
|
|
|
if permissions.Websocket != nil {
|
|
fmt.Printf("%swebsocket:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason)
|
|
fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork)
|
|
fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", "))
|
|
fmt.Println()
|
|
}
|
|
|
|
if permissions.Cache != nil {
|
|
fmt.Printf("%scache:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason)
|
|
fmt.Println()
|
|
}
|
|
|
|
if permissions.Artwork != nil {
|
|
fmt.Printf("%sartwork:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason)
|
|
fmt.Println()
|
|
}
|
|
|
|
if permissions.Subsonicapi != nil {
|
|
allowedUsers := "All Users"
|
|
if len(permissions.Subsonicapi.AllowedUsernames) > 0 {
|
|
allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ")
|
|
}
|
|
fmt.Printf("%ssubsonicapi:\n", indent)
|
|
fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason)
|
|
fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins)
|
|
fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers)
|
|
fmt.Println()
|
|
}
|
|
}
|
|
|
|
func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) {
|
|
fmt.Println("\nPlugin Information:")
|
|
fmt.Printf(" Name: %s\n", manifest.Name)
|
|
fmt.Printf(" Author: %s\n", manifest.Author)
|
|
fmt.Printf(" Version: %s\n", manifest.Version)
|
|
fmt.Printf(" Description: %s\n", manifest.Description)
|
|
|
|
fmt.Print(" Capabilities: ")
|
|
capabilities := make([]string, len(manifest.Capabilities))
|
|
for i, cap := range manifest.Capabilities {
|
|
capabilities[i] = string(cap)
|
|
}
|
|
fmt.Print(strings.Join(capabilities, ", "))
|
|
fmt.Println()
|
|
|
|
// Display manifest permissions using the typed permissions
|
|
fmt.Println(" Required Permissions:")
|
|
displayTypedPermissions(manifest.Permissions, " ")
|
|
|
|
// Print file information if available
|
|
if fileInfo != nil {
|
|
fmt.Println("Package Information:")
|
|
fmt.Printf(" File: %s\n", fileInfo.path)
|
|
fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024)
|
|
fmt.Printf(" SHA-256: %s\n", fileInfo.hash)
|
|
fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339))
|
|
}
|
|
|
|
// Print file permissions information if available
|
|
if permInfo != nil {
|
|
fmt.Println("File Permissions:")
|
|
fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode)
|
|
if permInfo.isSymlink {
|
|
fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode)
|
|
}
|
|
fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode)
|
|
if permInfo.wasmMode != "" {
|
|
fmt.Printf(" WASM File: %s\n", permInfo.wasmMode)
|
|
}
|
|
}
|
|
}
|
|
|
|
type pluginFileInfo struct {
|
|
path string
|
|
size int64
|
|
hash string
|
|
modTime time.Time
|
|
}
|
|
|
|
type pluginPermissionInfo struct {
|
|
dirPath string
|
|
dirMode string
|
|
isSymlink bool
|
|
targetPath string
|
|
targetMode string
|
|
manifestMode string
|
|
wasmMode string
|
|
}
|
|
|
|
func getFileInfo(path string) *pluginFileInfo {
|
|
fileInfo, err := os.Stat(path)
|
|
if err != nil {
|
|
log.Error("Failed to get file information", err)
|
|
return nil
|
|
}
|
|
|
|
return &pluginFileInfo{
|
|
path: path,
|
|
size: fileInfo.Size(),
|
|
hash: calculateSHA256(path),
|
|
modTime: fileInfo.ModTime(),
|
|
}
|
|
}
|
|
|
|
func getPermissionInfo(pluginDir string) *pluginPermissionInfo {
|
|
// Get plugin directory permissions
|
|
dirInfo, err := os.Lstat(pluginDir)
|
|
if err != nil {
|
|
log.Error("Failed to get plugin directory permissions", err)
|
|
return nil
|
|
}
|
|
|
|
permInfo := &pluginPermissionInfo{
|
|
dirPath: pluginDir,
|
|
dirMode: dirInfo.Mode().String(),
|
|
}
|
|
|
|
// Check if it's a symlink
|
|
if dirInfo.Mode()&os.ModeSymlink != 0 {
|
|
permInfo.isSymlink = true
|
|
|
|
// Get target path and permissions
|
|
targetPath, err := os.Readlink(pluginDir)
|
|
if err == nil {
|
|
if !filepath.IsAbs(targetPath) {
|
|
targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath)
|
|
}
|
|
permInfo.targetPath = targetPath
|
|
|
|
if targetInfo, err := os.Stat(targetPath); err == nil {
|
|
permInfo.targetMode = targetInfo.Mode().String()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get manifest file permissions
|
|
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
|
if manifestInfo, err := os.Stat(manifestPath); err == nil {
|
|
permInfo.manifestMode = manifestInfo.Mode().String()
|
|
}
|
|
|
|
// Get WASM file permissions (look for .wasm files)
|
|
entries, err := os.ReadDir(pluginDir)
|
|
if err == nil {
|
|
for _, entry := range entries {
|
|
if filepath.Ext(entry.Name()) == ".wasm" {
|
|
wasmPath := filepath.Join(pluginDir, entry.Name())
|
|
if wasmInfo, err := os.Stat(wasmPath); err == nil {
|
|
permInfo.wasmMode = wasmInfo.Mode().String()
|
|
break // Just show the first WASM file found
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return permInfo
|
|
}
|
|
|
|
// Command implementations
|
|
|
|
func pluginList(cmd *cobra.Command, args []string) {
|
|
discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder)
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION")
|
|
|
|
for _, discovery := range discoveries {
|
|
displayPluginTableRow(w, discovery)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func pluginInfo(cmd *cobra.Command, args []string) {
|
|
path := args[0]
|
|
pluginsDir := conf.Server.Plugins.Folder
|
|
|
|
var manifest *schema.PluginManifest
|
|
var fileInfo *pluginFileInfo
|
|
var permInfo *pluginPermissionInfo
|
|
|
|
if filepath.Ext(path) == pluginPackageExtension {
|
|
// It's a package file
|
|
pkg, err := loadAndValidatePackage(path)
|
|
if err != nil {
|
|
log.Fatal("Failed to load plugin package", err)
|
|
}
|
|
manifest = pkg.Manifest
|
|
fileInfo = getFileInfo(path)
|
|
// No permission info for package files
|
|
} else {
|
|
// It's a plugin name
|
|
pluginDir, err := validatePluginDirectory(pluginsDir, path)
|
|
if err != nil {
|
|
log.Fatal("Plugin validation failed", err)
|
|
}
|
|
|
|
manifest, err = plugins.LoadManifest(pluginDir)
|
|
if err != nil {
|
|
log.Fatal("Failed to load plugin manifest", err)
|
|
}
|
|
|
|
// Get permission info for installed plugins
|
|
permInfo = getPermissionInfo(pluginDir)
|
|
}
|
|
|
|
displayPluginDetails(manifest, fileInfo, permInfo)
|
|
}
|
|
|
|
func pluginInstall(cmd *cobra.Command, args []string) {
|
|
ndpPath := args[0]
|
|
pluginsDir := conf.Server.Plugins.Folder
|
|
|
|
pkg, err := loadAndValidatePackage(ndpPath)
|
|
if err != nil {
|
|
log.Fatal("Package validation failed", err)
|
|
}
|
|
|
|
// Create target directory based on plugin name
|
|
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
|
|
|
|
// Check if plugin already exists
|
|
if utils.FileExists(targetDir) {
|
|
log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir,
|
|
"use", "navidrome plugin update")
|
|
}
|
|
|
|
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
|
|
log.Fatal("Plugin installation failed", err)
|
|
}
|
|
|
|
fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
|
|
}
|
|
|
|
func pluginRemove(cmd *cobra.Command, args []string) {
|
|
pluginName := args[0]
|
|
pluginsDir := conf.Server.Plugins.Folder
|
|
|
|
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
|
|
if err != nil {
|
|
log.Fatal("Plugin validation failed", err)
|
|
}
|
|
|
|
_, isSymlink, err := resolvePluginPath(pluginDir)
|
|
if err != nil {
|
|
log.Fatal("Failed to resolve plugin path", err)
|
|
}
|
|
|
|
if isSymlink {
|
|
// For symlinked plugins (dev mode), just remove the symlink
|
|
if err := os.Remove(pluginDir); err != nil {
|
|
log.Fatal("Failed to remove plugin symlink", "name", pluginName, err)
|
|
}
|
|
fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName)
|
|
} else {
|
|
// For regular plugins, remove the entire directory
|
|
if err := os.RemoveAll(pluginDir); err != nil {
|
|
log.Fatal("Failed to remove plugin directory", "name", pluginName, err)
|
|
}
|
|
fmt.Printf("Plugin '%s' removed successfully\n", pluginName)
|
|
}
|
|
}
|
|
|
|
func pluginUpdate(cmd *cobra.Command, args []string) {
|
|
ndpPath := args[0]
|
|
pluginsDir := conf.Server.Plugins.Folder
|
|
|
|
pkg, err := loadAndValidatePackage(ndpPath)
|
|
if err != nil {
|
|
log.Fatal("Package validation failed", err)
|
|
}
|
|
|
|
// Check if plugin exists
|
|
targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name)
|
|
if !utils.FileExists(targetDir) {
|
|
log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir,
|
|
"use", "navidrome plugin install")
|
|
}
|
|
|
|
// Create a backup of the existing plugin
|
|
backupDir := targetDir + ".bak." + time.Now().Format("20060102150405")
|
|
if err := os.Rename(targetDir, backupDir); err != nil {
|
|
log.Fatal("Failed to backup existing plugin", err)
|
|
}
|
|
|
|
// Extract the new package
|
|
if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil {
|
|
// Restore backup if extraction failed
|
|
os.RemoveAll(targetDir)
|
|
_ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path
|
|
log.Fatal("Plugin update failed", err)
|
|
}
|
|
|
|
// Remove the backup
|
|
os.RemoveAll(backupDir)
|
|
|
|
fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version)
|
|
}
|
|
|
|
func pluginRefresh(cmd *cobra.Command, args []string) {
|
|
pluginName := args[0]
|
|
pluginsDir := conf.Server.Plugins.Folder
|
|
|
|
pluginDir, err := validatePluginDirectory(pluginsDir, pluginName)
|
|
if err != nil {
|
|
log.Fatal("Plugin validation failed", err)
|
|
}
|
|
|
|
resolvedPath, isSymlink, err := resolvePluginPath(pluginDir)
|
|
if err != nil {
|
|
log.Fatal("Failed to resolve plugin path", err)
|
|
}
|
|
|
|
if isSymlink {
|
|
log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath)
|
|
}
|
|
|
|
fmt.Printf("Refreshing plugin '%s'...\n", pluginName)
|
|
|
|
// Get the plugin manager and refresh
|
|
mgr := GetPluginManager(cmd.Context())
|
|
log.Debug("Scanning plugins directory", "path", pluginsDir)
|
|
mgr.ScanPlugins()
|
|
|
|
log.Info("Waiting for plugin compilation to complete", "name", pluginName)
|
|
|
|
// Wait for compilation to complete
|
|
if err := mgr.EnsureCompiled(pluginName); err != nil {
|
|
log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err)
|
|
}
|
|
|
|
log.Info("Plugin compilation completed successfully", "name", pluginName)
|
|
fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName)
|
|
}
|
|
|
|
func pluginDev(cmd *cobra.Command, args []string) {
|
|
sourcePath, err := filepath.Abs(args[0])
|
|
if err != nil {
|
|
log.Fatal("Invalid path", "path", args[0], err)
|
|
}
|
|
pluginsDir := conf.Server.Plugins.Folder
|
|
|
|
// Validate source directory and manifest
|
|
if err := validateDevSource(sourcePath); err != nil {
|
|
log.Fatal("Source validation failed", err)
|
|
}
|
|
|
|
// Load manifest to get plugin name
|
|
manifest, err := plugins.LoadManifest(sourcePath)
|
|
if err != nil {
|
|
log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err)
|
|
}
|
|
|
|
pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath))
|
|
targetPath := filepath.Join(pluginsDir, pluginName)
|
|
|
|
// Handle existing target
|
|
if err := handleExistingTarget(targetPath, sourcePath); err != nil {
|
|
log.Fatal("Failed to handle existing target", err)
|
|
}
|
|
|
|
// Create target directory if needed
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err)
|
|
}
|
|
|
|
// Create the symlink
|
|
if err := os.Symlink(sourcePath, targetPath); err != nil {
|
|
log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err)
|
|
}
|
|
|
|
fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath)
|
|
fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName)
|
|
}
|
|
|
|
// Utility functions
|
|
|
|
func validateDevSource(sourcePath string) error {
|
|
sourceInfo, err := os.Stat(sourcePath)
|
|
if err != nil {
|
|
return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err)
|
|
}
|
|
if !sourceInfo.IsDir() {
|
|
return fmt.Errorf("source path is not a directory: %s", sourcePath)
|
|
}
|
|
|
|
manifestPath := filepath.Join(sourcePath, "manifest.json")
|
|
if !utils.FileExists(manifestPath) {
|
|
return fmt.Errorf("source folder missing manifest.json: %s", sourcePath)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func handleExistingTarget(targetPath, sourcePath string) error {
|
|
if !utils.FileExists(targetPath) {
|
|
return nil // Nothing to handle
|
|
}
|
|
|
|
// Check if it's already a symlink to our source
|
|
existingLink, err := os.Readlink(targetPath)
|
|
if err == nil && existingLink == sourcePath {
|
|
fmt.Printf("Symlink already exists and points to the correct source\n")
|
|
return fmt.Errorf("symlink already exists") // This will cause early return in caller
|
|
}
|
|
|
|
// Handle case where target exists but is not a symlink to our source
|
|
fmt.Printf("Target path '%s' already exists.\n", targetPath)
|
|
fmt.Print("Do you want to replace it? (y/N): ")
|
|
var response string
|
|
_, err = fmt.Scanln(&response)
|
|
if err != nil || strings.ToLower(response) != "y" {
|
|
if err != nil {
|
|
log.Debug("Error reading input, assuming 'no'", err)
|
|
}
|
|
return fmt.Errorf("operation canceled")
|
|
}
|
|
|
|
// Remove existing target
|
|
if err := os.RemoveAll(targetPath); err != nil {
|
|
return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ensurePluginDirPermissions(dir string) {
|
|
if err := os.Chmod(dir, pluginDirPermissions); err != nil {
|
|
log.Error("Failed to set plugin directory permissions", "dir", dir, err)
|
|
}
|
|
|
|
// Apply permissions to all files in the directory
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
log.Error("Failed to read plugin directory", "dir", dir, err)
|
|
return
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
path := filepath.Join(dir, entry.Name())
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
log.Error("Failed to stat file", "path", path, err)
|
|
continue
|
|
}
|
|
|
|
mode := os.FileMode(pluginFilePermissions) // Files
|
|
if info.IsDir() {
|
|
mode = os.FileMode(pluginDirPermissions) // Directories
|
|
ensurePluginDirPermissions(path) // Recursive
|
|
}
|
|
|
|
if err := os.Chmod(path, mode); err != nil {
|
|
log.Error("Failed to set file permissions", "path", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func calculateSHA256(filePath string) string {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
log.Error("Failed to open file for hashing", err)
|
|
return "N/A"
|
|
}
|
|
defer file.Close()
|
|
|
|
hasher := sha256.New()
|
|
if _, err := io.Copy(hasher, file); err != nil {
|
|
log.Error("Failed to calculate hash", err)
|
|
return "N/A"
|
|
}
|
|
|
|
return hex.EncodeToString(hasher.Sum(nil))
|
|
}
|