update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
716
cmd/plugin.go
Normal file
716
cmd/plugin.go
Normal file
@@ -0,0 +1,716 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user