mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-18 11:48:07 -05:00
Compare commits
30 Commits
feat/now-p
...
remove_def
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ccc18ba02 | ||
|
|
c5447a637a | ||
|
|
b9247ba34e | ||
|
|
510acde3db | ||
|
|
13be8e6dfb | ||
|
|
9ab0c2dc67 | ||
|
|
032cfa2a4d | ||
|
|
84bf4fac04 | ||
|
|
8485371ad3 | ||
|
|
d45d306492 | ||
|
|
6d47a6ebd9 | ||
|
|
14efb13cd4 | ||
|
|
3adc4eb8aa | ||
|
|
7b9bc1c5ac | ||
|
|
03a45753e9 | ||
|
|
fd4a04339e | ||
|
|
9d95ef7b3f | ||
|
|
55966ba5ec | ||
|
|
5c3568f758 | ||
|
|
735c0d9103 | ||
|
|
fc9817552d | ||
|
|
0c1b65d3e6 | ||
|
|
47b448c64f | ||
|
|
834fa494e4 | ||
|
|
5d34640065 | ||
|
|
9ed309ac81 | ||
|
|
8c80be56da | ||
|
|
cde5992c46 | ||
|
|
017676c457 | ||
|
|
2d7b716834 |
37
.github/workflows/pipeline.yml
vendored
37
.github/workflows/pipeline.yml
vendored
@@ -88,6 +88,16 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo 'Generated code is out of date. Run "make gen" and commit the changes'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go:
|
||||
name: Test Go code
|
||||
runs-on: ubuntu-latest
|
||||
@@ -108,6 +118,13 @@ jobs:
|
||||
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||
go test -shuffle=on -tags netgo -race ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
cd plugins/cmd/ndpgen
|
||||
go test -shuffle=on -v
|
||||
go build -o ndpgen .
|
||||
./ndpgen --help
|
||||
|
||||
js:
|
||||
name: Test JS code
|
||||
runs-on: ubuntu-latest
|
||||
@@ -217,7 +234,7 @@ jobs:
|
||||
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: Upload Binaries
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome-${{ env.PLATFORM }}
|
||||
path: ./output
|
||||
@@ -248,7 +265,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM }}
|
||||
@@ -270,7 +287,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -304,7 +321,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
@@ -356,7 +373,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-windows*
|
||||
@@ -375,7 +392,7 @@ jobs:
|
||||
du -h binaries/msi/*.msi
|
||||
|
||||
- name: Upload MSI files
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome-windows-installers
|
||||
path: binaries/msi/*.msi
|
||||
@@ -393,7 +410,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: ./binaries
|
||||
pattern: navidrome-*
|
||||
@@ -419,7 +436,7 @@ jobs:
|
||||
rm ./dist/*.tar.gz ./dist/*.zip
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: packages
|
||||
path: dist/navidrome_0*
|
||||
@@ -442,13 +459,13 @@ jobs:
|
||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||
steps:
|
||||
- name: Download all-packages artifact
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: packages
|
||||
path: ./dist
|
||||
|
||||
- name: Upload all-packages artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: navidrome_linux_${{ matrix.item }}
|
||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
with:
|
||||
process-only: 'issues, prs'
|
||||
issue-inactive-days: 120
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
git status --porcelain
|
||||
git diff
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.PAT }}
|
||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,6 +17,7 @@ master.zip
|
||||
testDB
|
||||
cache/*
|
||||
*.swp
|
||||
coverage.out
|
||||
dist
|
||||
music
|
||||
*.db*
|
||||
@@ -25,6 +26,7 @@ docker-compose.yml
|
||||
!contrib/docker-compose.yml
|
||||
binaries
|
||||
navidrome-*
|
||||
/ndpgen
|
||||
AGENTS.md
|
||||
.github/prompts
|
||||
.github/instructions
|
||||
@@ -32,4 +34,5 @@ AGENTS.md
|
||||
*.exe
|
||||
*.test
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
44
Makefile
44
Makefile
@@ -1,6 +1,9 @@
|
||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||
NODE_VERSION=$(shell cat .nvmrc)
|
||||
|
||||
# Set global environment variables, required for most targets
|
||||
export ND_ENABLEINSIGHTSCOLLECTOR=false
|
||||
|
||||
ifneq ("$(wildcard .git/HEAD)","")
|
||||
GIT_SHA=$(shell git rev-parse --short HEAD)
|
||||
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT
|
||||
@@ -16,7 +19,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||
GOLANGCI_LINT_VERSION ?= v2.6.2
|
||||
GOLANGCI_LINT_VERSION ?= v2.8.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@@ -26,11 +29,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
|
||||
.PHONY: setup
|
||||
|
||||
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
||||
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
|
||||
npx foreman -j Procfile.dev -p 4533 start
|
||||
.PHONY: dev
|
||||
|
||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
||||
go tool reflex -d none -c reflex.conf
|
||||
.PHONY: server
|
||||
|
||||
stop: ##@Development Stop development servers (UI and backend)
|
||||
@@ -50,7 +53,11 @@ test: ##@Development Run Go tests. Use PKG variable to specify packages to test,
|
||||
go test -tags netgo $(PKG)
|
||||
.PHONY: test
|
||||
|
||||
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
||||
cd plugins/cmd/ndpgen && go test ./......
|
||||
.PHONY: test-ndpgen
|
||||
|
||||
testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
test-race: ##@Development Run Go tests with race detector
|
||||
@@ -85,7 +92,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
|
||||
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
@@ -103,6 +110,15 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags=netgo ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host
|
||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -python -rust
|
||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust
|
||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities
|
||||
go mod tidy -C plugins/pdk/go
|
||||
.PHONY: gen
|
||||
|
||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||
.PHONY: snapshots
|
||||
@@ -266,24 +282,6 @@ deprecated:
|
||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||
.PHONY: deprecated
|
||||
|
||||
# Generate Go code from plugins/api/api.proto
|
||||
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
|
||||
go generate ./plugins/...
|
||||
.PHONY: plugin-gen
|
||||
|
||||
plugin-examples: check_go_env ##@Development Build all example plugins
|
||||
$(MAKE) -C plugins/examples clean all
|
||||
.PHONY: plugin-examples
|
||||
|
||||
plugin-clean: check_go_env ##@Development Clean all plugins
|
||||
$(MAKE) -C plugins/examples clean
|
||||
$(MAKE) -C plugins/testdata clean
|
||||
.PHONY: plugin-clean
|
||||
|
||||
plugin-tests: check_go_env ##@Development Build all test plugins
|
||||
$(MAKE) -C plugins/testdata clean all
|
||||
.PHONY: plugin-tests
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
HELP_FUN = \
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package taglib
|
||||
|
||||
/*
|
||||
#cgo !windows pkg-config: --define-prefix taglib
|
||||
#cgo windows pkg-config: taglib
|
||||
#cgo pkg-config: taglib
|
||||
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
|
||||
#cgo linux darwin CXXFLAGS: -std=c++11
|
||||
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
|
||||
|
||||
716
cmd/plugin.go
716
cmd/plugin.go
@@ -1,716 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var _ = Describe("Plugin CLI Commands", func() {
|
||||
var tempDir string
|
||||
var cmd *cobra.Command
|
||||
var stdOut *os.File
|
||||
var origStdout *os.File
|
||||
var outReader *os.File
|
||||
|
||||
// Helper to create a test plugin with the given name and details
|
||||
createTestPlugin := func(name, author, version string, capabilities []string) string {
|
||||
pluginDir := filepath.Join(tempDir, name)
|
||||
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||
|
||||
// Create a properly formatted capabilities JSON array
|
||||
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
|
||||
|
||||
manifest := `{
|
||||
"name": "` + name + `",
|
||||
"author": "` + author + `",
|
||||
"version": "` + version + `",
|
||||
"description": "Plugin for testing",
|
||||
"website": "https://test.navidrome.org/` + name + `",
|
||||
"capabilities": [` + capabilitiesJSON + `],
|
||||
"permissions": {}
|
||||
}`
|
||||
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
|
||||
// Create a dummy WASM file
|
||||
wasmContent := []byte("dummy wasm content for testing")
|
||||
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||
|
||||
return pluginDir
|
||||
}
|
||||
|
||||
// Helper to execute a command and return captured output
|
||||
captureOutput := func(reader io.Reader) string {
|
||||
stdOut.Close()
|
||||
outputBytes, err := io.ReadAll(reader)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
return string(outputBytes)
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
tempDir = GinkgoT().TempDir()
|
||||
|
||||
// Setup config
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tempDir
|
||||
|
||||
// Create a command for testing
|
||||
cmd = &cobra.Command{Use: "test"}
|
||||
|
||||
// Setup stdout capture
|
||||
origStdout = os.Stdout
|
||||
var err error
|
||||
outReader, stdOut, err = os.Pipe()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
os.Stdout = stdOut
|
||||
|
||||
DeferCleanup(func() {
|
||||
os.Stdout = origStdout
|
||||
})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.Stdout = origStdout
|
||||
if stdOut != nil {
|
||||
stdOut.Close()
|
||||
}
|
||||
if outReader != nil {
|
||||
outReader.Close()
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Plugin list command", func() {
|
||||
It("should list installed plugins", func() {
|
||||
// Create test plugins
|
||||
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
|
||||
|
||||
// Execute command
|
||||
pluginList(cmd, []string{})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
|
||||
Expect(output).To(ContainSubstring("plugin1"))
|
||||
Expect(output).To(ContainSubstring("Test Author"))
|
||||
Expect(output).To(ContainSubstring("1.0.0"))
|
||||
Expect(output).To(ContainSubstring("MetadataAgent"))
|
||||
|
||||
Expect(output).To(ContainSubstring("plugin2"))
|
||||
Expect(output).To(ContainSubstring("Another Author"))
|
||||
Expect(output).To(ContainSubstring("2.1.0"))
|
||||
Expect(output).To(ContainSubstring("Scrobbler"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin info command", func() {
|
||||
It("should display information about an installed plugin", func() {
|
||||
// Create test plugin with multiple capabilities
|
||||
createTestPlugin("test-plugin", "Test Author", "1.0.0",
|
||||
[]string{"MetadataAgent", "Scrobbler"})
|
||||
|
||||
// Execute command
|
||||
pluginInfo(cmd, []string{"test-plugin"})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
|
||||
Expect(output).To(ContainSubstring("Name: test-plugin"))
|
||||
Expect(output).To(ContainSubstring("Author: Test Author"))
|
||||
Expect(output).To(ContainSubstring("Version: 1.0.0"))
|
||||
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
|
||||
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin remove command", func() {
|
||||
It("should remove a regular plugin directory", func() {
|
||||
// Create test plugin
|
||||
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
|
||||
[]string{"MetadataAgent"})
|
||||
|
||||
// Execute command
|
||||
pluginRemove(cmd, []string{"regular-plugin"})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
|
||||
|
||||
// Verify directory is actually removed
|
||||
_, err := os.Stat(pluginDir)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should remove only the symlink for a development plugin", func() {
|
||||
// Create a real source directory
|
||||
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
|
||||
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
|
||||
|
||||
manifest := `{
|
||||
"name": "dev-plugin",
|
||||
"author": "Dev Author",
|
||||
"version": "0.1.0",
|
||||
"description": "Development plugin for testing",
|
||||
"website": "https://test.navidrome.org/dev-plugin",
|
||||
"capabilities": ["Scrobbler"],
|
||||
"permissions": {}
|
||||
}`
|
||||
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||
|
||||
// Create a dummy WASM file
|
||||
wasmContent := []byte("dummy wasm content for testing")
|
||||
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||
|
||||
// Create a symlink in the plugins directory
|
||||
symlinkPath := filepath.Join(tempDir, "dev-plugin")
|
||||
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
|
||||
|
||||
// Execute command
|
||||
pluginRemove(cmd, []string{"dev-plugin"})
|
||||
|
||||
// Verify output
|
||||
output := captureOutput(outReader)
|
||||
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
|
||||
Expect(output).To(ContainSubstring("target directory preserved"))
|
||||
|
||||
// Verify the symlink is removed but source directory exists
|
||||
_, err := os.Lstat(symlinkPath)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
|
||||
_, err = os.Stat(sourceDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
17
cmd/root.go
17
cmd/root.go
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -22,6 +21,13 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
// Import adapters to register them
|
||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -330,16 +336,13 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||
// startPluginManager starts the plugin manager, if configured.
|
||||
func startPluginManager(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
manager := GetPluginManager(ctx)
|
||||
if !conf.Server.Plugins.Enabled {
|
||||
log.Debug("Plugins are DISABLED")
|
||||
log.Debug("Plugin system is DISABLED")
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Starting plugin manager")
|
||||
// Get the manager instance and scan for plugins
|
||||
manager := GetPluginManager(ctx)
|
||||
manager.ScanPlugins()
|
||||
|
||||
return nil
|
||||
return manager.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
cmd/scan.go
46
cmd/scan.go
@@ -1,9 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@@ -19,12 +22,14 @@ var (
|
||||
fullScan bool
|
||||
subprocess bool
|
||||
targets []string
|
||||
targetFile string
|
||||
)
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
||||
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
||||
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
}
|
||||
|
||||
@@ -71,10 +76,17 @@ func runScanner(ctx context.Context) {
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := core.NewPlaylists(ds)
|
||||
|
||||
// Parse targets if provided
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
if len(targets) > 0 {
|
||||
var err error
|
||||
var err error
|
||||
|
||||
if targetFile != "" {
|
||||
scanTargets, err = readTargetsFromFile(targetFile)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to read targets from file", err)
|
||||
}
|
||||
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
|
||||
} else if len(targets) > 0 {
|
||||
scanTargets, err = model.ParseTargets(targets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to parse targets", err)
|
||||
@@ -94,3 +106,31 @@ func runScanner(ctx context.Context) {
|
||||
trackScanInteractively(ctx, progress)
|
||||
}
|
||||
}
|
||||
|
||||
// readTargetsFromFile reads scan targets from a file, one per line.
|
||||
// Each line should be in the format "libraryID:folderPath".
|
||||
// Empty lines and lines starting with # are ignored.
|
||||
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open target file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var targetStrings []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and comments
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
targetStrings = append(targetStrings, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read target file: %w", err)
|
||||
}
|
||||
|
||||
return model.ParseTargets(targetStrings)
|
||||
}
|
||||
|
||||
89
cmd/scan_test.go
Normal file
89
cmd/scan_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("readTargetsFromFile", func() {
|
||||
var tempDir string
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tempDir, err = os.MkdirTemp("", "navidrome-test-")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tempDir)
|
||||
})
|
||||
|
||||
It("reads valid targets from file", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(3))
|
||||
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
|
||||
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
|
||||
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
|
||||
})
|
||||
|
||||
It("skips empty lines", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("trims whitespace", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
||||
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
_, err := readTargetsFromFile("/nonexistent/file.txt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
|
||||
})
|
||||
|
||||
It("returns error for invalid target format", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "invalid-format\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = readTargetsFromFile(filePath)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles mixed valid and empty lines", func() {
|
||||
filePath := filepath.Join(tempDir, "targets.txt")
|
||||
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
|
||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
targets, err := readTargetsFromFile(filePath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(targets).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
@@ -9,10 +9,10 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/adapters/lastfm"
|
||||
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
@@ -32,6 +32,10 @@ import (
|
||||
)
|
||||
|
||||
import (
|
||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||
)
|
||||
|
||||
@@ -47,9 +51,7 @@ func CreateServer() *server.Server {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
insights := metrics.GetInstance(dataStore, manager)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
serverServer := server.New(dataStore, broker, insights)
|
||||
return serverServer
|
||||
}
|
||||
@@ -59,21 +61,22 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
insights := metrics.GetInstance(dataStore, manager)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||
router := nativeapi.New(dataStore, share, playlists, insights, library, user, maintenance, manager)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -82,8 +85,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -93,7 +97,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
@@ -107,8 +110,9 @@ func CreatePublicRouter() *public.Router {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
@@ -137,9 +141,7 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||
func CreateInsights() metrics.Insights {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
insights := metrics.GetInstance(dataStore, manager)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
return insights
|
||||
}
|
||||
|
||||
@@ -155,13 +157,13 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
return modelScanner
|
||||
@@ -172,13 +174,13 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
@@ -192,19 +194,20 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
return playbackServer
|
||||
}
|
||||
|
||||
func getPluginManager() plugins.Manager {
|
||||
func getPluginManager() *plugins.Manager {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
return manager
|
||||
}
|
||||
|
||||
// wire_injectors.go:
|
||||
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/adapters/lastfm"
|
||||
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
@@ -39,12 +39,14 @@ var allProviders = wire.NewSet(
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.GetWatcher,
|
||||
plugins.GetManager,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
||||
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
|
||||
plugins.GetManager,
|
||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||
)
|
||||
|
||||
@@ -120,13 +122,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() plugins.Manager {
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||
return manager
|
||||
|
||||
@@ -89,7 +89,6 @@ type configOptions struct {
|
||||
PasswordEncryptionKey string
|
||||
ExtAuth extAuthOptions
|
||||
Plugins pluginsOptions
|
||||
PluginConfig map[string]map[string]string
|
||||
HTTPHeaders httpHeaderOptions `json:",omitzero"`
|
||||
Prometheus prometheusOptions `json:",omitzero"`
|
||||
Scanner scannerOptions `json:",omitzero"`
|
||||
@@ -154,6 +153,7 @@ type subsonicOptions struct {
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
LegacyClients string
|
||||
MinimalClients string
|
||||
}
|
||||
|
||||
type TagConf struct {
|
||||
@@ -226,9 +226,11 @@ type inspectOptions struct {
|
||||
}
|
||||
|
||||
type pluginsOptions struct {
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
Enabled bool
|
||||
Folder string
|
||||
CacheSize string
|
||||
AutoReload bool
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
type extAuthOptions struct {
|
||||
@@ -633,7 +635,8 @@ func setViperDefaults() {
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("plugins.folder", "")
|
||||
viper.SetDefault("plugins.enabled", false)
|
||||
viper.SetDefault("plugins.cachesize", "100MB")
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
|
||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||
viper.SetDefault("devlogsourceline", false)
|
||||
|
||||
@@ -150,6 +150,8 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
var HTTPUserAgent = "Navidrome" + "/" + Version
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
// TODO This will be dynamic when using disambiguation
|
||||
|
||||
@@ -64,6 +64,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||
if a.pluginLoader != nil {
|
||||
availablePlugins = a.pluginLoader.PluginNames("MetadataAgent")
|
||||
}
|
||||
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
||||
|
||||
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||
|
||||
@@ -354,6 +355,9 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
||||
continue
|
||||
}
|
||||
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
|
||||
}
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||
"mbid", mbid, "elapsed", time.Since(start))
|
||||
|
||||
@@ -22,6 +22,7 @@ type AlbumInfo struct {
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
}
|
||||
@@ -32,6 +33,7 @@ type ExternalImage struct {
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ func fromAlbumExternalSource(ctx context.Context, al model.Album, provider exter
|
||||
func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) {
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
201
core/external/provider.go
vendored
201
core/external/provider.go
vendored
@@ -12,10 +12,6 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/deezer"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
@@ -426,17 +422,21 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, mbidMatches)
|
||||
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count)
|
||||
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||
@@ -477,9 +477,41 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (m
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
titleMap := map[string]string{}
|
||||
for _, s := range songs {
|
||||
// Skip if already matched by ID or MBID
|
||||
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||
continue
|
||||
}
|
||||
@@ -518,18 +550,27 @@ func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, a
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
// Try ID match first
|
||||
if t.ID != "" {
|
||||
if mf, ok := byID[t.ID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Try MBID match second
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall back to title match
|
||||
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
@@ -593,36 +634,51 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis
|
||||
var result model.Artists
|
||||
var notPresent []string
|
||||
|
||||
artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name })
|
||||
|
||||
// Query all artists at once
|
||||
clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer {
|
||||
return squirrel.Like{"artist.name": name}
|
||||
})
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Or(clauses),
|
||||
})
|
||||
// Load artists by ID (highest priority)
|
||||
idMatches, err := e.loadArtistsByID(ctx, similar)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
artistMap := make(map[string]model.Artist)
|
||||
for _, artist := range artists {
|
||||
artistMap[artist.Name] = artist
|
||||
// Load artists by MBID (second priority)
|
||||
mbidMatches, err := e.loadArtistsByMBID(ctx, similar, idMatches)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load artists by name (lowest priority, fallback)
|
||||
nameMatches, err := e.loadArtistsByName(ctx, similar, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
// Process the similar artists
|
||||
// Process the similar artists using priority: ID → MBID → Name
|
||||
for _, s := range similar {
|
||||
if artist, found := artistMap[s.Name]; found {
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
// Try ID match first
|
||||
if s.ID != "" {
|
||||
if artist, found := idMatches[s.ID]; found {
|
||||
result = append(result, artist)
|
||||
count++
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Try MBID match second
|
||||
if s.MBID != "" {
|
||||
if artist, found := mbidMatches[s.MBID]; found {
|
||||
result = append(result, artist)
|
||||
count++
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall back to name match
|
||||
if artist, found := nameMatches[s.Name]; found {
|
||||
result = append(result, artist)
|
||||
count++
|
||||
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
notPresent = append(notPresent, s.Name)
|
||||
}
|
||||
@@ -645,6 +701,95 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) (map[string]model.Artist, error) {
|
||||
var ids []string
|
||||
for _, s := range similar {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.Artist{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, a := range res {
|
||||
if _, ok := matches[a.ID]; !ok {
|
||||
matches[a.ID] = a
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist) (map[string]model.Artist, error) {
|
||||
var mbids []string
|
||||
for _, s := range similar {
|
||||
// Skip if already matched by ID
|
||||
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.Artist{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, a := range res {
|
||||
if id := a.MbzArtistID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist, mbidMatches map[string]model.Artist) (map[string]model.Artist, error) {
|
||||
var names []string
|
||||
for _, s := range similar {
|
||||
// Skip if already matched by ID or MBID
|
||||
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||
continue
|
||||
}
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
matches := map[string]model.Artist{}
|
||||
if len(names) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
||||
return squirrel.Like{"artist.name": name}
|
||||
})
|
||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Or(clauses),
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, a := range res {
|
||||
if _, ok := matches[a.Name]; !ok {
|
||||
matches[a.Name] = a
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
|
||||
11
core/external/provider_artistradio_test.go
vendored
11
core/external/provider_artistradio_test.go
vendored
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -67,8 +68,16 @@ var _ = Describe("Provider - ArtistRadio", func() {
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(similarAgentsResp, nil).Once()
|
||||
|
||||
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
|
||||
// MBID lookup returns empty (no match)
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
// Name lookup returns the similar artist
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Or)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
|
||||
62
core/external/provider_topsongs_test.go
vendored
62
core/external/provider_topsongs_test.go
vendored
@@ -4,10 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@@ -271,4 +271,60 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("matches songs by ID first when agent provides IDs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response with IDs provided (highest priority matching)
|
||||
// Note: Songs have no MBID to ensure only ID matching is used
|
||||
agentSongs := []agents.Song{
|
||||
{ID: "song-1", Name: "Song One"},
|
||||
{ID: "song-2", Name: "Song Two"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock ID lookup (first query - should match both songs directly)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("falls back to MBID when ID is not found", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response with ID that won't be found, but MBID that will
|
||||
agentSongs := []agents.Song{
|
||||
{ID: "non-existent-id", Name: "Song One", MBID: "mbid-song-1"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock ID lookup - returns empty (ID not found)
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once()
|
||||
// Mock MBID lookup - finds the song
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
|
||||
84
core/external/provider_updateartistinfo_test.go
vendored
84
core/external/provider_updateartistinfo_test.go
vendored
@@ -226,4 +226,88 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("matches similar artists by ID first when agent provides IDs", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-id-match",
|
||||
Name: "ID Match Artist",
|
||||
}
|
||||
similarByID := model.Artist{ID: "ar-similar-by-id", Name: "Similar By ID", MbzArtistID: "mbid-similar"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarByID})
|
||||
|
||||
// Agent returns similar artist with ID (highest priority matching)
|
||||
rawSimilar := []agents.Artist{
|
||||
{ID: "ar-similar-by-id", Name: "Different Name", MBID: "different-mbid"},
|
||||
}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-id-match", "ID Match Artist").Return("", nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-id-match", "ID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-id-match", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
// Should match by ID, not by name or MBID
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-id"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By ID"))
|
||||
})
|
||||
|
||||
It("matches similar artists by MBID when ID is empty", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-mbid-match",
|
||||
Name: "MBID Match Artist",
|
||||
}
|
||||
similarByMBID := model.Artist{ID: "ar-similar-by-mbid", Name: "Similar By MBID", MbzArtistID: "mbid-similar"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarByMBID})
|
||||
|
||||
// Agent returns similar artist with only MBID (no ID)
|
||||
rawSimilar := []agents.Artist{
|
||||
{Name: "Different Name", MBID: "mbid-similar"},
|
||||
}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-mbid-match", "MBID Match Artist").Return("", nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-mbid-match", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
// Should match by MBID since ID was empty
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-mbid"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By MBID"))
|
||||
})
|
||||
|
||||
It("falls back to name matching when ID and MBID don't match", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-name-match",
|
||||
Name: "Name Match Artist",
|
||||
}
|
||||
similarByName := model.Artist{ID: "ar-similar-by-name", Name: "Similar By Name"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarByName})
|
||||
|
||||
// Agent returns similar artist with non-matching ID and MBID
|
||||
rawSimilar := []agents.Artist{
|
||||
{ID: "non-existent-id", Name: "Similar By Name", MBID: "non-existent-mbid"},
|
||||
}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-name-match", "Name Match Artist").Return("", nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-name-match", "Name Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-name-match", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
// Should fall back to name matching since ID and MBID didn't match
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-name"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By Name"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,19 +37,21 @@ type Library interface {
|
||||
}
|
||||
|
||||
type libraryService struct {
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
// NewLibrary creates a new Library service
|
||||
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
|
||||
func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library {
|
||||
return &libraryService{
|
||||
ds: ds,
|
||||
scanner: scanner,
|
||||
watcher: watcher,
|
||||
broker: broker,
|
||||
ds: ds,
|
||||
scanner: scanner,
|
||||
watcher: watcher,
|
||||
broker: broker,
|
||||
pluginManager: pluginManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +143,7 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
||||
scanner: s.scanner,
|
||||
watcher: s.watcher,
|
||||
broker: s.broker,
|
||||
pluginManager: s.pluginManager,
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
@@ -148,11 +151,12 @@ func (s *libraryService) NewRepository(ctx context.Context) rest.Repository {
|
||||
type libraryRepositoryWrapper struct {
|
||||
rest.Repository
|
||||
model.LibraryRepository
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
ctx context.Context
|
||||
ds model.DataStore
|
||||
scanner model.Scanner
|
||||
watcher Watcher
|
||||
broker events.Broker
|
||||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
@@ -272,6 +276,10 @@ func (r *libraryRepositoryWrapper) Delete(id string) error {
|
||||
log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name)
|
||||
}
|
||||
|
||||
// After successful deletion, check if any plugins were auto-disabled
|
||||
// and need to be unloaded from memory
|
||||
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ var _ = Describe("Library Service", func() {
|
||||
var scanner *tests.MockScanner
|
||||
var watcherManager *mockWatcherManager
|
||||
var broker *mockEventBroker
|
||||
var pluginManager *mockPluginUnloader
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
@@ -50,7 +51,9 @@ var _ = Describe("Library Service", func() {
|
||||
}
|
||||
// Create a mock event broker
|
||||
broker = &mockEventBroker{}
|
||||
service = core.NewLibrary(ds, scanner, watcherManager, broker)
|
||||
// Create a mock plugin unloader
|
||||
pluginManager = &mockPluginUnloader{}
|
||||
service = core.NewLibrary(ds, scanner, watcherManager, broker, pluginManager)
|
||||
ctx = context.Background()
|
||||
|
||||
// Create a temporary directory for testing valid paths
|
||||
@@ -869,8 +872,45 @@ var _ = Describe("Library Service", func() {
|
||||
Expect(broker.Events).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin Manager Integration", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
// Reset the call count for each test
|
||||
pluginManager.unloadCalls = 0
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
})
|
||||
|
||||
It("calls UnloadDisabledPlugins after successful library deletion", func() {
|
||||
libraryRepo.SetData(model.Libraries{
|
||||
{ID: 2, Name: "Library to Delete", Path: tempDir},
|
||||
})
|
||||
|
||||
err := repo.Delete("2")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(pluginManager.unloadCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not call UnloadDisabledPlugins when library deletion fails", func() {
|
||||
// Try to delete non-existent library
|
||||
err := repo.Delete("999")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockPluginUnloader is a simple mock for testing UnloadDisabledPlugins calls
|
||||
type mockPluginUnloader struct {
|
||||
unloadCalls int
|
||||
}
|
||||
|
||||
func (m *mockPluginUnloader) UnloadDisabledPlugins(ctx context.Context) {
|
||||
m.unloadCalls++
|
||||
}
|
||||
|
||||
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
|
||||
type mockWatcherManager struct {
|
||||
StartedWatchers []model.Library
|
||||
|
||||
@@ -23,7 +23,8 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/schema"
|
||||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
@@ -37,18 +38,12 @@ var (
|
||||
)
|
||||
|
||||
type insightsCollector struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
lastRun atomic.Int64
|
||||
lastStatus atomic.Bool
|
||||
ds model.DataStore
|
||||
lastRun atomic.Int64
|
||||
lastStatus atomic.Bool
|
||||
}
|
||||
|
||||
// PluginLoader defines an interface for loading plugins
|
||||
type PluginLoader interface {
|
||||
PluginList() map[string]schema.PluginManifest
|
||||
}
|
||||
|
||||
func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
||||
func GetInstance(ds model.DataStore) Insights {
|
||||
return singleton.GetInstance(func() *insightsCollector {
|
||||
id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey)
|
||||
if err != nil {
|
||||
@@ -60,7 +55,7 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
|
||||
}
|
||||
}
|
||||
insightsID = id
|
||||
return &insightsCollector{ds: ds, pluginLoader: pluginLoader}
|
||||
return &insightsCollector{ds: ds}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,12 +314,16 @@ func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error)
|
||||
|
||||
// collectPlugins collects information about installed plugins
|
||||
func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo {
|
||||
plugins := make(map[string]insights.PluginInfo)
|
||||
for id, manifest := range c.pluginLoader.PluginList() {
|
||||
plugins[id] = insights.PluginInfo{
|
||||
Name: manifest.Name,
|
||||
Version: manifest.Version,
|
||||
// TODO Fix import/inject cycles
|
||||
manager := plugins.GetManager(c.ds, events.GetBroker(), nil)
|
||||
info := manager.GetPluginInfo()
|
||||
|
||||
result := make(map[string]insights.PluginInfo, len(info))
|
||||
for name, p := range info {
|
||||
result[name] = insights.PluginInfo{
|
||||
Name: p.Name,
|
||||
Version: p.Version,
|
||||
}
|
||||
}
|
||||
return plugins
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -168,6 +168,11 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -409,7 +414,10 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -473,6 +481,7 @@ type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
@@ -483,5 +492,8 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,27 @@ var _ = Describe("Playlists", func() {
|
||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||
})
|
||||
It("parses NSP with public: true and creates public playlist", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Public Playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
It("parses NSP with public: false and creates private playlist", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Private Playlist"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cross-library relative paths", func() {
|
||||
|
||||
81
core/publicurl/publicurl.go
Normal file
81
core/publicurl/publicurl.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package publicurl
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// ImageURL generates a public URL for artwork images.
|
||||
// It creates a signed token for the artwork ID and builds a complete public URL.
|
||||
func ImageURL(req *http.Request, artID model.ArtworkID, size int) string {
|
||||
token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()})
|
||||
uri := path.Join(consts.URLPathPublicImages, token)
|
||||
params := url.Values{}
|
||||
if size > 0 {
|
||||
params.Add("size", strconv.Itoa(size))
|
||||
}
|
||||
return PublicURL(req, uri, params)
|
||||
}
|
||||
|
||||
// PublicURL builds a full URL for public-facing resources.
|
||||
// It uses ShareURL from config if available, otherwise falls back to extracting
|
||||
// the scheme and host from the provided http.Request.
|
||||
// If req is nil and ShareURL is not set, it defaults to http://localhost.
|
||||
func PublicURL(req *http.Request, u string, params url.Values) string {
|
||||
if conf.Server.ShareURL == "" {
|
||||
return AbsoluteURL(req, u, params)
|
||||
}
|
||||
shareUrl, err := url.Parse(conf.Server.ShareURL)
|
||||
if err != nil {
|
||||
return AbsoluteURL(req, u, params)
|
||||
}
|
||||
buildUrl, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return AbsoluteURL(req, u, params)
|
||||
}
|
||||
buildUrl.Scheme = shareUrl.Scheme
|
||||
buildUrl.Host = shareUrl.Host
|
||||
if len(params) > 0 {
|
||||
buildUrl.RawQuery = params.Encode()
|
||||
}
|
||||
return buildUrl.String()
|
||||
}
|
||||
|
||||
// AbsoluteURL builds an absolute URL from a relative path.
|
||||
// It uses BaseHost/BaseScheme from config if available, otherwise extracts
|
||||
// the scheme and host from the http.Request.
|
||||
// If req is nil and BaseHost is not set, it defaults to http://localhost.
|
||||
func AbsoluteURL(req *http.Request, u string, params url.Values) string {
|
||||
buildUrl, err := url.Parse(u)
|
||||
if err != nil {
|
||||
log.Error(req.Context(), "Failed to parse URL path", "url", u, err)
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(u, "/") {
|
||||
buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path)
|
||||
if conf.Server.BaseHost != "" {
|
||||
buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http")
|
||||
buildUrl.Host = conf.Server.BaseHost
|
||||
} else if req != nil {
|
||||
buildUrl.Scheme = req.URL.Scheme
|
||||
buildUrl.Host = req.Host
|
||||
} else {
|
||||
buildUrl.Scheme = "http"
|
||||
buildUrl.Host = "localhost"
|
||||
}
|
||||
}
|
||||
if len(params) > 0 {
|
||||
buildUrl.RawQuery = params.Encode()
|
||||
}
|
||||
return buildUrl.String()
|
||||
}
|
||||
174
core/publicurl/publicurl_test.go
Normal file
174
core/publicurl/publicurl_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package publicurl_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/publicurl"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPublicURL(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Public URL Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("Public URL Utilities", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
Describe("PublicURL", func() {
|
||||
When("ShareURL is set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://share.example.com"
|
||||
})
|
||||
|
||||
It("uses ShareURL as the base", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.PublicURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("includes query parameters", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
params := url.Values{"size": []string{"300"}, "format": []string{"png"}}
|
||||
result := publicurl.PublicURL(r, "/image/123", params)
|
||||
Expect(result).To(ContainSubstring("https://share.example.com/image/123"))
|
||||
Expect(result).To(ContainSubstring("size=300"))
|
||||
Expect(result).To(ContainSubstring("format=png"))
|
||||
})
|
||||
|
||||
It("works without a request", func() {
|
||||
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://share.example.com/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
When("ShareURL is not set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = ""
|
||||
})
|
||||
|
||||
It("falls back to AbsoluteURL with request", func() {
|
||||
r, _ := http.NewRequest("GET", "https://myserver.com/test", nil)
|
||||
r.Host = "myserver.com"
|
||||
result := publicurl.PublicURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://myserver.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("falls back to localhost without request", func() {
|
||||
result := publicurl.PublicURL(nil, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("http://localhost/path/to/resource"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AbsoluteURL", func() {
|
||||
When("BaseHost is set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.BaseHost = "configured.example.com"
|
||||
conf.Server.BaseScheme = "https"
|
||||
conf.Server.BasePath = ""
|
||||
})
|
||||
|
||||
It("uses BaseHost and BaseScheme", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://configured.example.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("defaults to http scheme if BaseScheme is empty", func() {
|
||||
conf.Server.BaseScheme = ""
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("http://configured.example.com/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
When("BaseHost is not set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.BaseHost = ""
|
||||
conf.Server.BasePath = ""
|
||||
})
|
||||
|
||||
It("extracts host from request", func() {
|
||||
r, _ := http.NewRequest("GET", "https://request.example.com/test", nil)
|
||||
r.Host = "request.example.com"
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://request.example.com/path/to/resource"))
|
||||
})
|
||||
|
||||
It("falls back to localhost without request", func() {
|
||||
result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("http://localhost/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
When("BasePath is set", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.BasePath = "/navidrome"
|
||||
conf.Server.BaseHost = "example.com"
|
||||
conf.Server.BaseScheme = "https"
|
||||
})
|
||||
|
||||
It("prepends BasePath to the URL", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "/path/to/resource", nil)
|
||||
Expect(result).To(Equal("https://example.com/navidrome/path/to/resource"))
|
||||
})
|
||||
})
|
||||
|
||||
It("passes through absolute URLs unchanged", func() {
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil)
|
||||
Expect(result).To(Equal("https://other.example.com/path"))
|
||||
})
|
||||
|
||||
It("includes query parameters", func() {
|
||||
conf.Server.BaseHost = "example.com"
|
||||
conf.Server.BaseScheme = "https"
|
||||
r, _ := http.NewRequest("GET", "http://localhost/test", nil)
|
||||
params := url.Values{"key": []string{"value"}}
|
||||
result := publicurl.AbsoluteURL(r, "/path", params)
|
||||
Expect(result).To(Equal("https://example.com/path?key=value"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ImageURL", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.ShareURL = "https://share.example.com"
|
||||
// Initialize JWT auth for token generation
|
||||
auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil)
|
||||
})
|
||||
|
||||
It("generates a URL with the artwork token", func() {
|
||||
artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil)
|
||||
result := publicurl.ImageURL(nil, artID, 0)
|
||||
Expect(result).To(HavePrefix("https://share.example.com/share/img/"))
|
||||
})
|
||||
|
||||
It("includes size parameter when provided", func() {
|
||||
artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil)
|
||||
result := publicurl.ImageURL(nil, artID, 300)
|
||||
Expect(result).To(ContainSubstring("size=300"))
|
||||
})
|
||||
|
||||
It("omits size parameter when zero", func() {
|
||||
artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil)
|
||||
result := publicurl.ImageURL(nil, artID, 0)
|
||||
Expect(result).ToNot(ContainSubstring("size="))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,11 +9,27 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// Loader is a function that loads a scrobbler by name.
|
||||
// It returns the scrobbler and true if found, or nil and false if not available.
|
||||
// This allows the buffered scrobbler to always get the current plugin instance.
|
||||
type Loader func() (Scrobbler, bool)
|
||||
|
||||
// newBufferedScrobbler creates a buffered scrobbler that wraps a static scrobbler instance.
|
||||
// Use this for builtin scrobblers that don't change.
|
||||
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
|
||||
return newBufferedScrobblerWithLoader(ds, service, func() (Scrobbler, bool) {
|
||||
return s, true
|
||||
})
|
||||
}
|
||||
|
||||
// newBufferedScrobblerWithLoader creates a buffered scrobbler that dynamically loads
|
||||
// the underlying scrobbler on each call. Use this for plugin scrobblers that may be
|
||||
// reloaded (e.g., after configuration changes).
|
||||
func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader Loader) *bufferedScrobbler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b := &bufferedScrobbler{
|
||||
ds: ds,
|
||||
wrapped: s,
|
||||
loader: loader,
|
||||
service: service,
|
||||
wakeSignal: make(chan struct{}, 1),
|
||||
ctx: ctx,
|
||||
@@ -25,7 +41,7 @@ func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *buff
|
||||
|
||||
type bufferedScrobbler struct {
|
||||
ds model.DataStore
|
||||
wrapped Scrobbler
|
||||
loader Loader
|
||||
service string
|
||||
wakeSignal chan struct{}
|
||||
ctx context.Context
|
||||
@@ -39,11 +55,19 @@ func (b *bufferedScrobbler) Stop() {
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
return b.wrapped.IsAuthorized(ctx, userId)
|
||||
s, ok := b.loader()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return s.IsAuthorized(ctx, userId)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
return b.wrapped.NowPlaying(ctx, userId, track, position)
|
||||
s, ok := b.loader()
|
||||
if !ok {
|
||||
return errors.New("scrobbler not available")
|
||||
}
|
||||
return s.NowPlaying(ctx, userId, track, position)
|
||||
}
|
||||
|
||||
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
|
||||
@@ -107,8 +131,13 @@ func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string)
|
||||
if entry == nil {
|
||||
return true
|
||||
}
|
||||
s, ok := b.loader()
|
||||
if !ok {
|
||||
log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service)
|
||||
return false
|
||||
}
|
||||
log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist)
|
||||
err = b.wrapped.Scrobble(ctx, entry.UserID, Scrobble{
|
||||
err = s.Scrobble(ctx, entry.UserID, Scrobble{
|
||||
MediaFile: entry.MediaFile,
|
||||
TimeStamp: entry.PlayTime,
|
||||
})
|
||||
|
||||
@@ -116,7 +116,7 @@ func (p *playTracker) stopNowPlayingWorker() {
|
||||
<-p.workerDone // Wait for worker to finish
|
||||
}
|
||||
|
||||
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
|
||||
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers.
|
||||
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
|
||||
if len(pluginNames) != len(scrobblers) {
|
||||
return false
|
||||
@@ -129,7 +129,9 @@ func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scro
|
||||
return true
|
||||
}
|
||||
|
||||
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers
|
||||
// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers.
|
||||
// The buffered scrobblers use a loader function to dynamically get the current plugin instance,
|
||||
// so we only need to add/remove scrobblers when plugins are added/removed (not when reloaded).
|
||||
func (p *playTracker) refreshPluginScrobblers() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@@ -148,15 +150,16 @@ func (p *playTracker) refreshPluginScrobblers() {
|
||||
// Build a set of current plugins for faster lookups
|
||||
current := make(map[string]struct{}, len(pluginNames))
|
||||
|
||||
// Process additions - add new plugins
|
||||
// Process additions - add new plugins with a loader that dynamically fetches the current instance
|
||||
for _, name := range pluginNames {
|
||||
current[name] = struct{}{}
|
||||
// Only create a new scrobbler if it doesn't exist
|
||||
if _, exists := p.pluginScrobblers[name]; !exists {
|
||||
s, ok := p.pluginLoader.LoadScrobbler(name)
|
||||
if ok && s != nil {
|
||||
p.pluginScrobblers[name] = newBufferedScrobbler(p.ds, s, name)
|
||||
}
|
||||
// Capture the name for the closure
|
||||
pluginName := name
|
||||
loader := p.pluginLoader
|
||||
p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) {
|
||||
return loader.LoadScrobbler(pluginName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,122 @@ var _ = Describe("PlayTracker", func() {
|
||||
Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Plugin reload (config update) behavior", func() {
|
||||
var mockPlugin *mockPluginLoader
|
||||
var pTracker *playTracker
|
||||
var originalScrobbler *fakeScrobbler
|
||||
var reloadedScrobbler *fakeScrobbler
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
|
||||
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
|
||||
ds = &tests.MockDataStore{}
|
||||
|
||||
// Setup initial plugin scrobbler
|
||||
originalScrobbler = &fakeScrobbler{Authorized: true}
|
||||
reloadedScrobbler = &fakeScrobbler{Authorized: true}
|
||||
|
||||
mockPlugin = &mockPluginLoader{
|
||||
names: []string{"plugin1"},
|
||||
scrobblers: map[string]Scrobbler{"plugin1": originalScrobbler},
|
||||
}
|
||||
|
||||
// Create tracker - this will create buffered scrobblers with loaders
|
||||
pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin)
|
||||
|
||||
// Trigger initial plugin registration
|
||||
pTracker.refreshPluginScrobblers()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
pTracker.stopNowPlayingWorker()
|
||||
})
|
||||
|
||||
It("uses the new plugin instance after reload (simulating config update)", func() {
|
||||
// First call should use the original scrobbler
|
||||
scrobblers := pTracker.getActiveScrobblers()
|
||||
pluginScr := scrobblers["plugin1"]
|
||||
Expect(pluginScr).ToNot(BeNil())
|
||||
|
||||
err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||
Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeFalse())
|
||||
|
||||
// Simulate plugin reload (config update): replace the scrobbler in the loader
|
||||
// This is what happens when UpdatePluginConfig is called - the plugin manager
|
||||
// unloads the old plugin and loads a new instance
|
||||
mockPlugin.mu.Lock()
|
||||
mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
|
||||
mockPlugin.mu.Unlock()
|
||||
|
||||
// Reset call tracking
|
||||
originalScrobbler.nowPlayingCalled.Store(false)
|
||||
|
||||
// Get scrobblers again - should still return the same buffered scrobbler
|
||||
// but subsequent calls should use the new plugin instance via the loader
|
||||
scrobblers = pTracker.getActiveScrobblers()
|
||||
pluginScr = scrobblers["plugin1"]
|
||||
|
||||
err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// The new scrobbler should be called, not the old one
|
||||
Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("handles plugin becoming unavailable temporarily", func() {
|
||||
// First verify plugin works
|
||||
scrobblers := pTracker.getActiveScrobblers()
|
||||
pluginScr := scrobblers["plugin1"]
|
||||
|
||||
err := pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||
|
||||
// Simulate plugin becoming unavailable (e.g., during reload)
|
||||
mockPlugin.mu.Lock()
|
||||
delete(mockPlugin.scrobblers, "plugin1")
|
||||
mockPlugin.mu.Unlock()
|
||||
|
||||
originalScrobbler.nowPlayingCalled.Store(false)
|
||||
|
||||
// NowPlaying should return error when plugin unavailable
|
||||
err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse())
|
||||
|
||||
// Simulate plugin becoming available again
|
||||
mockPlugin.mu.Lock()
|
||||
mockPlugin.scrobblers["plugin1"] = reloadedScrobbler
|
||||
mockPlugin.mu.Unlock()
|
||||
|
||||
// Should work again with new instance
|
||||
err = pluginScr.NowPlaying(ctx, "u-1", &track, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("IsAuthorized uses the current plugin instance", func() {
|
||||
scrobblers := pTracker.getActiveScrobblers()
|
||||
pluginScr := scrobblers["plugin1"]
|
||||
|
||||
// Original is authorized
|
||||
Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeTrue())
|
||||
|
||||
// Replace with unauthorized scrobbler
|
||||
unauthorizedScrobbler := &fakeScrobbler{Authorized: false}
|
||||
mockPlugin.mu.Lock()
|
||||
mockPlugin.scrobblers["plugin1"] = unauthorizedScrobbler
|
||||
mockPlugin.mu.Unlock()
|
||||
|
||||
// Should reflect the new scrobbler's authorization status
|
||||
Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
type fakeScrobbler struct {
|
||||
|
||||
76
core/user.go
Normal file
76
core/user.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// PluginUnloader defines the interface for unloading disabled plugins.
|
||||
// This is satisfied by plugins.Manager but defined here to avoid import cycles.
|
||||
type PluginUnloader interface {
|
||||
UnloadDisabledPlugins(ctx context.Context)
|
||||
}
|
||||
|
||||
// User provides business logic for user management with plugin coordination.
|
||||
type User interface {
|
||||
NewRepository(ctx context.Context) rest.Repository
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
ds model.DataStore
|
||||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
// NewUser creates a new User service
|
||||
func NewUser(ds model.DataStore, pluginManager PluginUnloader) User {
|
||||
return &userService{
|
||||
ds: ds,
|
||||
pluginManager: pluginManager,
|
||||
}
|
||||
}
|
||||
|
||||
// NewRepository returns a REST repository wrapper for user operations.
|
||||
// The wrapper intercepts Delete operations to coordinate plugin unloading.
|
||||
func (s *userService) NewRepository(ctx context.Context) rest.Repository {
|
||||
repo := s.ds.User(ctx)
|
||||
wrapper := &userRepositoryWrapper{
|
||||
ctx: ctx,
|
||||
UserRepository: repo,
|
||||
pluginManager: s.pluginManager,
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
|
||||
type userRepositoryWrapper struct {
|
||||
model.UserRepository
|
||||
ctx context.Context
|
||||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
// Save implements rest.Persistable by delegating to the underlying repository.
|
||||
func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
return r.UserRepository.(rest.Persistable).Save(entity)
|
||||
}
|
||||
|
||||
// Update implements rest.Persistable by delegating to the underlying repository.
|
||||
func (r *userRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
|
||||
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
|
||||
}
|
||||
|
||||
// Delete implements rest.Persistable and coordinates plugin unloading.
|
||||
func (r *userRepositoryWrapper) Delete(id string) error {
|
||||
// The underlying repository Delete handles the database cleanup
|
||||
// including calling cleanupPluginUserReferences
|
||||
err := r.UserRepository.(rest.Persistable).Delete(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// After successful deletion, check if any plugins were auto-disabled
|
||||
// and need to be unloaded from memory
|
||||
r.pluginManager.UnloadDisabledPlugins(r.ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
86
core/user_test.go
Normal file
86
core/user_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("User Service", func() {
|
||||
var service core.User
|
||||
var ds *tests.MockDataStore
|
||||
var userRepo *tests.MockedUserRepo
|
||||
var pluginManager *mockPluginUnloader
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
ds.MockedUser = userRepo
|
||||
pluginManager = &mockPluginUnloader{}
|
||||
service = core.NewUser(ds, pluginManager)
|
||||
ctx = GinkgoT().Context()
|
||||
})
|
||||
|
||||
Describe("NewRepository", func() {
|
||||
It("returns a rest.Persistable", func() {
|
||||
repo := service.NewRepository(ctx)
|
||||
_, ok := repo.(rest.Persistable)
|
||||
Expect(ok).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
r := service.NewRepository(ctx)
|
||||
repo = r.(rest.Persistable)
|
||||
|
||||
// Add a test user
|
||||
user := &model.User{
|
||||
ID: "user-123",
|
||||
UserName: "testuser",
|
||||
IsAdmin: false,
|
||||
}
|
||||
user.NewPassword = "password"
|
||||
Expect(userRepo.Put(user)).To(Succeed())
|
||||
})
|
||||
|
||||
It("deletes the user successfully", func() {
|
||||
err := repo.Delete("user-123")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Verify user is deleted
|
||||
_, err = userRepo.Get("user-123")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("calls UnloadDisabledPlugins after successful deletion", func() {
|
||||
err := repo.Delete("user-123")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(pluginManager.unloadCalls).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not call UnloadDisabledPlugins when deletion fails", func() {
|
||||
// Try to delete non-existent user
|
||||
err := repo.Delete("non-existent")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||
})
|
||||
|
||||
It("returns error when repository fails", func() {
|
||||
userRepo.Error = errors.New("database error")
|
||||
err := repo.Delete("user-123")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("database error"))
|
||||
Expect(pluginManager.unloadCalls).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ var Set = wire.NewSet(
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
-- +goose Up
|
||||
-- Fix case-insensitive sorting for playlist names
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) collate NOCASE default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
path string default '' not null,
|
||||
sync bool default false not null,
|
||||
size integer default 0 not null,
|
||||
rules varchar,
|
||||
evaluated_at datetime,
|
||||
owner_id varchar(255) not null
|
||||
constraint playlist_user_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
|
||||
rules, evaluated_at, owner_id)
|
||||
select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
|
||||
owner_id
|
||||
from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp
|
||||
rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
create index playlist_created_at
|
||||
on playlist (created_at);
|
||||
|
||||
create index playlist_updated_at
|
||||
on playlist (updated_at);
|
||||
|
||||
create index playlist_evaluated_at
|
||||
on playlist (evaluated_at);
|
||||
|
||||
create index playlist_size
|
||||
on playlist (size);
|
||||
|
||||
-- +goose Down
|
||||
-- Note: Downgrade loses the collation but preserves data
|
||||
create table playlist_dg_tmp
|
||||
(
|
||||
id varchar(255) not null
|
||||
primary key,
|
||||
name varchar(255) default '' not null,
|
||||
comment varchar(255) default '' not null,
|
||||
duration real default 0 not null,
|
||||
song_count integer default 0 not null,
|
||||
public bool default FALSE not null,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
path string default '' not null,
|
||||
sync bool default false not null,
|
||||
size integer default 0 not null,
|
||||
rules varchar,
|
||||
evaluated_at datetime,
|
||||
owner_id varchar(255) not null
|
||||
constraint playlist_user_user_id_fk
|
||||
references user
|
||||
on update cascade on delete cascade
|
||||
);
|
||||
|
||||
insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size,
|
||||
rules, evaluated_at, owner_id)
|
||||
select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at,
|
||||
owner_id
|
||||
from playlist;
|
||||
|
||||
drop table playlist;
|
||||
|
||||
alter table playlist_dg_tmp
|
||||
rename to playlist;
|
||||
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
create index playlist_created_at
|
||||
on playlist (created_at);
|
||||
|
||||
create index playlist_updated_at
|
||||
on playlist (updated_at);
|
||||
|
||||
create index playlist_evaluated_at
|
||||
on playlist (evaluated_at);
|
||||
|
||||
create index playlist_size
|
||||
on playlist (size);
|
||||
19
db/migrations/20260106000620_create_plugin_table.sql
Normal file
19
db/migrations/20260106000620_create_plugin_table.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS plugin (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
manifest JSONB NOT NULL,
|
||||
config JSONB,
|
||||
users JSONB,
|
||||
all_users BOOL NOT NULL DEFAULT false,
|
||||
libraries JSONB,
|
||||
all_libraries BOOL NOT NULL DEFAULT false,
|
||||
enabled BOOL NOT NULL DEFAULT false,
|
||||
last_error TEXT,
|
||||
sha256 TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS plugin;
|
||||
61
go.mod
61
go.mod
@@ -9,7 +9,7 @@ require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
github.com/andybalholm/cascadia v1.3.3
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.2
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||
@@ -21,8 +21,9 @@ require (
|
||||
github.com/djherbis/stream v1.4.0
|
||||
github.com/djherbis/times v1.6.0
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/extism/go-sdk v1.7.1
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
@@ -36,16 +37,15 @@ require (
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/kardianos/service v1.2.4
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/knqyf263/go-plugin v0.9.0
|
||||
github.com/kr/pretty v0.3.1
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/maruel/natural v1.2.1
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.3
|
||||
github.com/onsi/ginkgo/v2 v2.27.5
|
||||
github.com/onsi/gomega v1.39.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
@@ -57,19 +57,17 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.10.1
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/text v0.33.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -81,20 +79,23 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/reflex v0.3.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f // indirect
|
||||
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
@@ -112,8 +113,8 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sanity-io/litter v1.5.8 // indirect
|
||||
@@ -123,17 +124,21 @@ require (
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.1 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
|
||||
120
go.sum
120
go.sum
@@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||
@@ -26,8 +26,9 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -55,6 +56,10 @@ github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
|
||||
github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw=
|
||||
github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -68,8 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
||||
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
@@ -85,12 +90,14 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
|
||||
github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -99,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f h1:HU1RgM6NALf/KW9HEY6zry3ADbDKcmpQ+hJedoNGQYQ=
|
||||
github.com/google/pprof v0.0.0-20251213031049-b05bdaca462f/go.mod h1:67FPmZWbr+KDT/VlpWtw6sO9XSjpJmLuHpoLmWiTGgY=
|
||||
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 h1:oKBqR+eQXiIM7X8K1JEg9aoTEePLq/c6Awe484abOuA=
|
||||
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -118,6 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
|
||||
@@ -134,8 +143,6 @@ github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zt
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI=
|
||||
github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -162,14 +169,14 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
|
||||
github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
@@ -186,10 +193,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -207,10 +214,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -253,20 +260,27 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
|
||||
github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q=
|
||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -284,12 +298,14 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -298,20 +314,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -323,8 +339,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -350,11 +366,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA=
|
||||
golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -363,8 +379,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -375,8 +391,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -386,8 +402,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
@@ -395,8 +411,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU=
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
20
log/log.go
20
log/log.go
@@ -88,11 +88,11 @@ func SetLevel(l Level) {
|
||||
}
|
||||
|
||||
func SetLevelString(l string) {
|
||||
level := levelFromString(l)
|
||||
level := ParseLogLevel(l)
|
||||
SetLevel(level)
|
||||
}
|
||||
|
||||
func levelFromString(l string) Level {
|
||||
func ParseLogLevel(l string) Level {
|
||||
envLevel := strings.ToLower(l)
|
||||
var level Level
|
||||
switch envLevel {
|
||||
@@ -118,7 +118,7 @@ func SetLogLevels(levels map[string]string) {
|
||||
defer loggerMu.Unlock()
|
||||
logLevels = nil
|
||||
for k, v := range levels {
|
||||
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
|
||||
logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)})
|
||||
}
|
||||
sort.Slice(logLevels, func(i, j int) bool {
|
||||
return logLevels[i].path > logLevels[j].path
|
||||
@@ -185,31 +185,31 @@ func IsGreaterOrEqualTo(level Level) bool {
|
||||
}
|
||||
|
||||
func Fatal(args ...interface{}) {
|
||||
log(LevelFatal, args...)
|
||||
Log(LevelFatal, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func Error(args ...interface{}) {
|
||||
log(LevelError, args...)
|
||||
Log(LevelError, args...)
|
||||
}
|
||||
|
||||
func Warn(args ...interface{}) {
|
||||
log(LevelWarn, args...)
|
||||
Log(LevelWarn, args...)
|
||||
}
|
||||
|
||||
func Info(args ...interface{}) {
|
||||
log(LevelInfo, args...)
|
||||
Log(LevelInfo, args...)
|
||||
}
|
||||
|
||||
func Debug(args ...interface{}) {
|
||||
log(LevelDebug, args...)
|
||||
Log(LevelDebug, args...)
|
||||
}
|
||||
|
||||
func Trace(args ...interface{}) {
|
||||
log(LevelTrace, args...)
|
||||
Log(LevelTrace, args...)
|
||||
}
|
||||
|
||||
func log(level Level, args ...interface{}) {
|
||||
func Log(level Level, args ...interface{}) {
|
||||
if !shouldLog(level, 3) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ type DataStore interface {
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
Plugin(ctx context.Context) PluginRepository
|
||||
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
|
||||
30
model/plugin.go
Normal file
30
model/plugin.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Plugin struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
Manifest string `structs:"manifest" json:"manifest"`
|
||||
Config string `structs:"config" json:"config,omitempty"`
|
||||
Users string `structs:"users" json:"users,omitempty"`
|
||||
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
|
||||
Libraries string `structs:"libraries" json:"libraries,omitempty"`
|
||||
AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"`
|
||||
Enabled bool `structs:"enabled" json:"enabled"`
|
||||
LastError string `structs:"last_error" json:"lastError,omitempty"`
|
||||
SHA256 string `structs:"sha256" json:"sha256"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Plugins []Plugin
|
||||
|
||||
type PluginRepository interface {
|
||||
ResourceRepository
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*Plugin, error)
|
||||
GetAll(options ...QueryOptions) (Plugins, error)
|
||||
Put(p *Plugin) error
|
||||
}
|
||||
@@ -46,6 +46,7 @@ type UserRepository interface {
|
||||
CountAll(...QueryOptions) (int64, error)
|
||||
Delete(id string) error
|
||||
Get(id string) (*User, error)
|
||||
GetAll(options ...QueryOptions) (Users, error)
|
||||
Put(*User) error
|
||||
UpdateLastLoginAt(id string) error
|
||||
UpdateLastAccessAt(id string) error
|
||||
|
||||
@@ -512,6 +512,70 @@ var _ = Describe("AlbumRepository", func() {
|
||||
// Clean up the test album created for this test
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
|
||||
})
|
||||
|
||||
It("removes stale role associations when artist role changes", func() {
|
||||
// Regression test for issue #4242: Composers displayed in albumartist list
|
||||
// This happens when an artist's role changes (e.g., was both albumartist and composer,
|
||||
// now only composer) and the old role association isn't properly removed.
|
||||
|
||||
// Create an artist that will have changing roles
|
||||
artist := &model.Artist{
|
||||
ID: "role-change-artist-1",
|
||||
Name: "Role Change Artist",
|
||||
OrderArtistName: "role change artist",
|
||||
}
|
||||
err := createArtistWithLibrary(artistRepo, artist, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Create album with artist as both albumartist and composer
|
||||
album := &model.Album{
|
||||
LibraryID: 1,
|
||||
ID: "test-album-role-change",
|
||||
Name: "Test Album Role Change",
|
||||
AlbumArtistID: "role-change-artist-1",
|
||||
AlbumArtist: "Role Change Artist",
|
||||
Participants: model.Participants{
|
||||
model.RoleAlbumArtist: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
model.RoleComposer: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = albumRepo.Put(album)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify initial state: artist has both albumartist and composer roles
|
||||
expected := []albumArtistRecord{
|
||||
{ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""},
|
||||
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
|
||||
}
|
||||
verifyAlbumArtists(album.ID, expected)
|
||||
|
||||
// Now update album so artist is ONLY a composer (remove albumartist role)
|
||||
album.Participants = model.Participants{
|
||||
model.RoleComposer: {
|
||||
{Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}},
|
||||
},
|
||||
}
|
||||
|
||||
err = albumRepo.Put(album)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Verify that the albumartist role was removed - only composer should remain
|
||||
// This is the key test: before the fix, the albumartist role would remain
|
||||
// causing composers to appear in the albumartist filter
|
||||
expectedAfter := []albumArtistRecord{
|
||||
{ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""},
|
||||
}
|
||||
verifyAlbumArtists(album.ID, expectedAfter)
|
||||
|
||||
// Clean up
|
||||
_, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ var _ = Describe("Collation", func() {
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("playlist.name", "playlist", "name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
)
|
||||
@@ -53,6 +54,7 @@ var _ = Describe("Collation", func() {
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||
)
|
||||
|
||||
@@ -95,45 +95,82 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
|
||||
}
|
||||
|
||||
func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
|
||||
// If no specific paths, return all folders in the library
|
||||
if len(targetPaths) == 0 {
|
||||
return r.getFolderUpdateInfoAll(lib)
|
||||
}
|
||||
|
||||
// Check if any path is root (return all folders)
|
||||
for _, targetPath := range targetPaths {
|
||||
if targetPath == "" || targetPath == "." {
|
||||
return r.getFolderUpdateInfoAll(lib)
|
||||
}
|
||||
}
|
||||
|
||||
// Process paths in batches to avoid SQLite's expression tree depth limit (max 1000).
|
||||
// Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit.
|
||||
const batchSize = 100
|
||||
result := make(map[string]model.FolderUpdateInfo)
|
||||
|
||||
for batch := range slices.Chunk(targetPaths, batchSize) {
|
||||
batchResult, err := r.getFolderUpdateInfoBatch(lib, batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for id, info := range batchResult {
|
||||
result[id] = info
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// getFolderUpdateInfoAll returns update info for all non-missing folders in the library
|
||||
func (r folderRepository) getFolderUpdateInfoAll(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
|
||||
where := And{
|
||||
Eq{"library_id": lib.ID},
|
||||
Eq{"missing": false},
|
||||
}
|
||||
return r.queryFolderUpdateInfo(where)
|
||||
}
|
||||
|
||||
// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants
|
||||
func (r folderRepository) getFolderUpdateInfoBatch(lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) {
|
||||
where := And{
|
||||
Eq{"library_id": lib.ID},
|
||||
Eq{"missing": false},
|
||||
}
|
||||
|
||||
// If specific paths are requested, include those folders and all their descendants
|
||||
if len(targetPaths) > 0 {
|
||||
// Collect folder IDs for exact target folders and path conditions for descendants
|
||||
folderIDs := make([]string, 0, len(targetPaths))
|
||||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
// Collect folder IDs for exact target folders and path conditions for descendants
|
||||
folderIDs := make([]string, 0, len(targetPaths))
|
||||
pathConditions := make(Or, 0, len(targetPaths)*2)
|
||||
|
||||
for _, targetPath := range targetPaths {
|
||||
if targetPath == "" || targetPath == "." {
|
||||
// Root path - include everything in this library
|
||||
pathConditions = Or{}
|
||||
folderIDs = nil
|
||||
break
|
||||
}
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
for _, targetPath := range targetPaths {
|
||||
// Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
|
||||
cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
|
||||
cleanPath = filepath.Clean(cleanPath)
|
||||
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
// Include the target folder itself by ID
|
||||
folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
|
||||
|
||||
// Include all descendants: folders whose path field equals or starts with the target path
|
||||
// Note: Folder.Path is the directory path, so children have path = targetPath
|
||||
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
||||
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
||||
}
|
||||
|
||||
// Combine conditions: exact folder IDs OR descendant path patterns
|
||||
if len(folderIDs) > 0 {
|
||||
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
||||
} else if len(pathConditions) > 0 {
|
||||
where = append(where, pathConditions)
|
||||
}
|
||||
// Include all descendants: folders whose path field equals or starts with the target path
|
||||
// Note: Folder.Path is the directory path, so children have path = targetPath
|
||||
pathConditions = append(pathConditions, Eq{"path": cleanPath})
|
||||
pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
|
||||
}
|
||||
|
||||
// Combine conditions: exact folder IDs OR descendant path patterns
|
||||
if len(folderIDs) > 0 {
|
||||
where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
|
||||
} else if len(pathConditions) > 0 {
|
||||
where = append(where, pathConditions)
|
||||
}
|
||||
|
||||
return r.queryFolderUpdateInfo(where)
|
||||
}
|
||||
|
||||
// queryFolderUpdateInfo executes the query and returns the result map
|
||||
func (r folderRepository) queryFolderUpdateInfo(where And) (map[string]model.FolderUpdateInfo, error) {
|
||||
sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
|
||||
var res []struct {
|
||||
ID string
|
||||
|
||||
@@ -266,6 +266,10 @@ func (r *libraryRepository) Delete(id int) error {
|
||||
defer libLock.Unlock()
|
||||
delete(libCache, id)
|
||||
|
||||
// Clean up orphaned plugin references for the deleted library
|
||||
if err := cleanupPluginLibraryReferences(r.db, id); err != nil {
|
||||
log.Error(r.ctx, "Failed to cleanup plugin library references", "libraryID", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -332,15 +332,18 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC
|
||||
}
|
||||
|
||||
// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries
|
||||
// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching
|
||||
func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(And{
|
||||
NotEq{"media_file.library_id": missing.LibraryID},
|
||||
Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
|
||||
NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
|
||||
Eq{"media_file.suffix": missing.Suffix},
|
||||
Gt{"media_file.created_at": since},
|
||||
Eq{"media_file.missing": false},
|
||||
}).OrderBy("media_file.created_at DESC")
|
||||
sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
|
||||
LeftJoin("library on media_file.library_id = library.id").
|
||||
Where(And{
|
||||
NotEq{"media_file.library_id": missing.LibraryID},
|
||||
Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID},
|
||||
NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs
|
||||
Eq{"media_file.suffix": missing.Suffix},
|
||||
Gt{"media_file.created_at": since},
|
||||
Eq{"media_file.missing": false},
|
||||
}).OrderBy("media_file.created_at DESC")
|
||||
|
||||
var res dbMediaFiles
|
||||
err := r.queryAll(sel, &res)
|
||||
@@ -351,19 +354,22 @@ func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFil
|
||||
}
|
||||
|
||||
// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries
|
||||
// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching
|
||||
func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) {
|
||||
sel := r.selectMediaFile().Where(And{
|
||||
NotEq{"media_file.library_id": missing.LibraryID},
|
||||
Eq{"media_file.title": missing.Title},
|
||||
Eq{"media_file.size": missing.Size},
|
||||
Eq{"media_file.suffix": missing.Suffix},
|
||||
Eq{"media_file.disc_number": missing.DiscNumber},
|
||||
Eq{"media_file.track_number": missing.TrackNumber},
|
||||
Eq{"media_file.album": missing.Album},
|
||||
Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
|
||||
Gt{"media_file.created_at": since},
|
||||
Eq{"media_file.missing": false},
|
||||
}).OrderBy("media_file.created_at DESC")
|
||||
sel := r.newSelect().Columns("media_file.*", "library.path as library_path", "library.name as library_name").
|
||||
LeftJoin("library on media_file.library_id = library.id").
|
||||
Where(And{
|
||||
NotEq{"media_file.library_id": missing.LibraryID},
|
||||
Eq{"media_file.title": missing.Title},
|
||||
Eq{"media_file.size": missing.Size},
|
||||
Eq{"media_file.suffix": missing.Suffix},
|
||||
Eq{"media_file.disc_number": missing.DiscNumber},
|
||||
Eq{"media_file.track_number": missing.TrackNumber},
|
||||
Eq{"media_file.album": missing.Album},
|
||||
Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID
|
||||
Gt{"media_file.created_at": since},
|
||||
Eq{"media_file.missing": false},
|
||||
}).OrderBy("media_file.created_at DESC")
|
||||
|
||||
var res dbMediaFiles
|
||||
err := r.queryAll(sel, &res)
|
||||
|
||||
@@ -93,6 +93,10 @@ func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
|
||||
return NewScrobbleRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||
return NewPluginRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
@@ -117,6 +121,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
||||
return s.Share(ctx).(model.ResourceRepository)
|
||||
case model.Tag:
|
||||
return s.Tag(ctx).(model.ResourceRepository)
|
||||
case model.Plugin:
|
||||
return s.Plugin(ctx).(model.ResourceRepository)
|
||||
}
|
||||
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||
return nil
|
||||
|
||||
86
persistence/plugin_cleanup.go
Normal file
86
persistence/plugin_cleanup.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// cleanupPluginUserReferences removes a user ID from all plugins' users JSON arrays
|
||||
// and auto-disables plugins that lose their only permitted user (when users permission is required).
|
||||
// This is called from userRepository.Delete() to maintain referential integrity.
|
||||
func cleanupPluginUserReferences(db dbx.Builder, userID string) error {
|
||||
// SQLite JSON function: json_remove removes the element at the path where user matches.
|
||||
// We use a subquery with json_each to find and remove the user ID from the array.
|
||||
// This updates all plugins where the users array contains the given user ID.
|
||||
_, err := db.NewQuery(`
|
||||
UPDATE plugin
|
||||
SET users = (
|
||||
SELECT json_group_array(value)
|
||||
FROM json_each(plugin.users)
|
||||
WHERE value != {:userID}
|
||||
),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE users IS NOT NULL
|
||||
AND users != ''
|
||||
AND EXISTS (SELECT 1 FROM json_each(plugin.users) WHERE value = {:userID})
|
||||
`).Bind(dbx.Params{"userID": userID}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-disable plugins that:
|
||||
// 1. Are currently enabled
|
||||
// 2. Require users permission (manifest has permissions.users)
|
||||
// 3. Don't have allUsers enabled
|
||||
// 4. Now have an empty users array after cleanup
|
||||
//
|
||||
// The manifest check uses JSON path to see if permissions.users exists.
|
||||
_, err = db.NewQuery(`
|
||||
UPDATE plugin
|
||||
SET enabled = false,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE enabled = true
|
||||
AND all_users = false
|
||||
AND json_extract(manifest, '$.permissions.users') IS NOT NULL
|
||||
AND (users IS NULL OR users = '' OR users = '[]' OR json_array_length(users) = 0)
|
||||
`).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanupPluginLibraryReferences removes a library ID from all plugins' libraries JSON arrays
|
||||
// and auto-disables plugins that lose their only permitted library (when library permission is required).
|
||||
// This is called from libraryRepository.Delete() to maintain referential integrity.
|
||||
func cleanupPluginLibraryReferences(db dbx.Builder, libraryID int) error {
|
||||
// SQLite JSON function: we filter out the library ID from the array.
|
||||
// Libraries are stored as integers in the JSON array.
|
||||
_, err := db.NewQuery(`
|
||||
UPDATE plugin
|
||||
SET libraries = (
|
||||
SELECT json_group_array(value)
|
||||
FROM json_each(plugin.libraries)
|
||||
WHERE CAST(value AS INTEGER) != {:libraryID}
|
||||
),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE libraries IS NOT NULL
|
||||
AND libraries != ''
|
||||
AND EXISTS (SELECT 1 FROM json_each(plugin.libraries) WHERE CAST(value AS INTEGER) = {:libraryID})
|
||||
`).Bind(dbx.Params{"libraryID": libraryID}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Auto-disable plugins that:
|
||||
// 1. Are currently enabled
|
||||
// 2. Require library permission (manifest has permissions.library)
|
||||
// 3. Don't have allLibraries enabled
|
||||
// 4. Now have an empty libraries array after cleanup
|
||||
_, err = db.NewQuery(`
|
||||
UPDATE plugin
|
||||
SET enabled = false,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE enabled = true
|
||||
AND all_libraries = false
|
||||
AND json_extract(manifest, '$.permissions.library') IS NOT NULL
|
||||
AND (libraries IS NULL OR libraries = '' OR libraries = '[]' OR json_array_length(libraries) = 0)
|
||||
`).Execute()
|
||||
return err
|
||||
}
|
||||
263
persistence/plugin_cleanup_test.go
Normal file
263
persistence/plugin_cleanup_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Plugin Cleanup", func() {
|
||||
var pluginRepo model.PluginRepository
|
||||
var userRepo model.UserRepository
|
||||
var libraryRepo model.LibraryRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin", UserName: "admin", IsAdmin: true})
|
||||
db := GetDBXBuilder()
|
||||
pluginRepo = NewPluginRepository(ctx, db)
|
||||
userRepo = NewUserRepository(ctx, db)
|
||||
libraryRepo = NewLibraryRepository(ctx, db)
|
||||
|
||||
// Clean up any existing plugins
|
||||
all, _ := pluginRepo.GetAll()
|
||||
for _, p := range all {
|
||||
_ = pluginRepo.Delete(p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up after tests
|
||||
all, _ := pluginRepo.GetAll()
|
||||
for _, p := range all {
|
||||
_ = pluginRepo.Delete(p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
Describe("cleanupPluginUserReferences", func() {
|
||||
It("removes user ID from plugin users array", func() {
|
||||
// Create a plugin with multiple users
|
||||
plugin := &model.Plugin{
|
||||
ID: "test-plugin",
|
||||
Path: "/plugins/test.wasm",
|
||||
Manifest: `{"name":"test"}`,
|
||||
SHA256: "abc123",
|
||||
Users: `["user1","user2","user3"]`,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Clean up user2 reference
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginUserReferences(db, "user2")).To(Succeed())
|
||||
|
||||
// Verify user2 was removed
|
||||
updated, err := pluginRepo.Get("test-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Users).To(Equal(`["user1","user3"]`))
|
||||
Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled
|
||||
})
|
||||
|
||||
It("auto-disables plugin when last permitted user is removed", func() {
|
||||
// Create a plugin that requires users permission with only one user
|
||||
plugin := &model.Plugin{
|
||||
ID: "user-plugin",
|
||||
Path: "/plugins/user.wasm",
|
||||
Manifest: `{"name":"user-plugin","permissions":{"users":{}}}`,
|
||||
SHA256: "def456",
|
||||
Users: `["only-user"]`,
|
||||
AllUsers: false,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Remove the only user
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginUserReferences(db, "only-user")).To(Succeed())
|
||||
|
||||
// Verify plugin was auto-disabled
|
||||
updated, err := pluginRepo.Get("user-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Users).To(Equal(`[]`))
|
||||
Expect(updated.Enabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not disable plugin when allUsers is true", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "all-users-plugin",
|
||||
Path: "/plugins/all.wasm",
|
||||
Manifest: `{"name":"all-users","permissions":{"users":{}}}`,
|
||||
SHA256: "ghi789",
|
||||
Users: `["user1"]`,
|
||||
AllUsers: true,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Remove the user (but allUsers is true)
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed())
|
||||
|
||||
// Plugin should still be enabled because allUsers is true
|
||||
updated, err := pluginRepo.Get("all-users-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Enabled).To(BeTrue())
|
||||
})
|
||||
|
||||
It("does not affect plugins without users permission requirement", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "no-users-perm",
|
||||
Path: "/plugins/noperm.wasm",
|
||||
Manifest: `{"name":"no-perm"}`, // No permissions.users in manifest
|
||||
SHA256: "jkl012",
|
||||
Users: `["user1"]`,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Remove the user
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed())
|
||||
|
||||
// Plugin should still be enabled (no users permission requirement)
|
||||
updated, err := pluginRepo.Get("no-users-perm")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Users).To(Equal(`[]`))
|
||||
Expect(updated.Enabled).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("cleanupPluginLibraryReferences", func() {
|
||||
It("removes library ID from plugin libraries array", func() {
|
||||
// Create a plugin with multiple libraries
|
||||
plugin := &model.Plugin{
|
||||
ID: "lib-plugin",
|
||||
Path: "/plugins/lib.wasm",
|
||||
Manifest: `{"name":"lib-plugin"}`,
|
||||
SHA256: "mno345",
|
||||
Libraries: `[1,2,3]`,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Clean up library 2 reference
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginLibraryReferences(db, 2)).To(Succeed())
|
||||
|
||||
// Verify library 2 was removed
|
||||
updated, err := pluginRepo.Get("lib-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Libraries).To(Equal(`[1,3]`))
|
||||
})
|
||||
|
||||
It("auto-disables plugin when last permitted library is removed", func() {
|
||||
// Create a plugin that requires library permission with only one library
|
||||
plugin := &model.Plugin{
|
||||
ID: "lib-only-plugin",
|
||||
Path: "/plugins/libonly.wasm",
|
||||
Manifest: `{"name":"lib-only","permissions":{"library":{}}}`,
|
||||
SHA256: "pqr678",
|
||||
Libraries: `[99]`,
|
||||
AllLibraries: false,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Remove the only library
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginLibraryReferences(db, 99)).To(Succeed())
|
||||
|
||||
// Verify plugin was auto-disabled
|
||||
updated, err := pluginRepo.Get("lib-only-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Libraries).To(Equal(`[]`))
|
||||
Expect(updated.Enabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("does not disable plugin when allLibraries is true", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "all-libs-plugin",
|
||||
Path: "/plugins/alllibs.wasm",
|
||||
Manifest: `{"name":"all-libs","permissions":{"library":{}}}`,
|
||||
SHA256: "stu901",
|
||||
Libraries: `[1]`,
|
||||
AllLibraries: true,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Remove the library (but allLibraries is true)
|
||||
db := GetDBXBuilder()
|
||||
Expect(cleanupPluginLibraryReferences(db, 1)).To(Succeed())
|
||||
|
||||
// Plugin should still be enabled
|
||||
updated, err := pluginRepo.Get("all-libs-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Enabled).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("User Delete integration", func() {
|
||||
It("cleans up plugin references when user is deleted", func() {
|
||||
// Create a test user
|
||||
user := &model.User{
|
||||
ID: "test-delete-user",
|
||||
UserName: "plugin-cleanup-test-user",
|
||||
IsAdmin: false,
|
||||
}
|
||||
user.NewPassword = "password123"
|
||||
Expect(userRepo.Put(user)).To(Succeed())
|
||||
|
||||
// Create a plugin referencing this user
|
||||
plugin := &model.Plugin{
|
||||
ID: "user-ref-plugin",
|
||||
Path: "/plugins/userref.wasm",
|
||||
Manifest: `{"name":"user-ref"}`,
|
||||
SHA256: "xyz123",
|
||||
Users: `["test-delete-user","other-user"]`,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Delete the user
|
||||
Expect(userRepo.Delete("test-delete-user")).To(Succeed())
|
||||
|
||||
// Verify user was removed from plugin
|
||||
updated, err := pluginRepo.Get("user-ref-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Users).To(Equal(`["other-user"]`))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Library Delete integration", func() {
|
||||
It("cleans up plugin references when library is deleted", func() {
|
||||
// Create a test library (ID > 1 since ID 1 cannot be deleted)
|
||||
library := &model.Library{
|
||||
ID: 99,
|
||||
Name: "Test Library",
|
||||
Path: "/tmp/test-lib",
|
||||
}
|
||||
Expect(libraryRepo.Put(library)).To(Succeed())
|
||||
|
||||
// Create a plugin referencing this library
|
||||
plugin := &model.Plugin{
|
||||
ID: "lib-ref-plugin",
|
||||
Path: "/plugins/libref.wasm",
|
||||
Manifest: `{"name":"lib-ref"}`,
|
||||
SHA256: "abc789",
|
||||
Libraries: `[99,1]`,
|
||||
Enabled: true,
|
||||
}
|
||||
Expect(pluginRepo.Put(plugin)).To(Succeed())
|
||||
|
||||
// Delete the library
|
||||
Expect(libraryRepo.Delete(99)).To(Succeed())
|
||||
|
||||
// Verify library was removed from plugin
|
||||
updated, err := pluginRepo.Get("lib-ref-plugin")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(updated.Libraries).To(Equal(`[1]`))
|
||||
})
|
||||
})
|
||||
})
|
||||
161
persistence/plugin_repository.go
Normal file
161
persistence/plugin_repository.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type pluginRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPluginRepository(ctx context.Context, db dbx.Builder) model.PluginRepository {
|
||||
r := &pluginRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.registerModel(&model.Plugin{}, map[string]filterFunc{
|
||||
"id": idFilter("plugin"),
|
||||
"enabled": booleanFilter,
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *pluginRepository) isPermitted() bool {
|
||||
user := loggedUser(r.ctx)
|
||||
return user.IsAdmin
|
||||
}
|
||||
|
||||
func (r *pluginRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
if !r.isPermitted() {
|
||||
return 0, rest.ErrPermissionDenied
|
||||
}
|
||||
sql := r.newSelect()
|
||||
return r.count(sql, options...)
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Delete(id string) error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return r.delete(Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Get(id string) (*model.Plugin, error) {
|
||||
if !r.isPermitted() {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
sel := r.newSelect().Where(Eq{"id": id}).Columns("*")
|
||||
res := model.Plugin{}
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) GetAll(options ...model.QueryOptions) (model.Plugins, error) {
|
||||
if !r.isPermitted() {
|
||||
return nil, rest.ErrPermissionDenied
|
||||
}
|
||||
sel := r.newSelect(options...).Columns("*")
|
||||
res := model.Plugins{}
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Put(plugin *model.Plugin) error {
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
plugin.UpdatedAt = time.Now()
|
||||
|
||||
if plugin.ID == "" {
|
||||
return errors.New("plugin ID cannot be empty")
|
||||
}
|
||||
|
||||
// Upsert using INSERT ... ON CONFLICT for atomic operation
|
||||
_, err := r.db.NewQuery(`
|
||||
INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, enabled, last_error, sha256, created_at, updated_at)
|
||||
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
path = excluded.path,
|
||||
manifest = excluded.manifest,
|
||||
config = excluded.config,
|
||||
users = excluded.users,
|
||||
all_users = excluded.all_users,
|
||||
libraries = excluded.libraries,
|
||||
all_libraries = excluded.all_libraries,
|
||||
enabled = excluded.enabled,
|
||||
last_error = excluded.last_error,
|
||||
sha256 = excluded.sha256,
|
||||
updated_at = excluded.updated_at
|
||||
`).Bind(dbx.Params{
|
||||
"id": plugin.ID,
|
||||
"path": plugin.Path,
|
||||
"manifest": plugin.Manifest,
|
||||
"config": plugin.Config,
|
||||
"users": plugin.Users,
|
||||
"all_users": plugin.AllUsers,
|
||||
"libraries": plugin.Libraries,
|
||||
"all_libraries": plugin.AllLibraries,
|
||||
"enabled": plugin.Enabled,
|
||||
"last_error": plugin.LastError,
|
||||
"sha256": plugin.SHA256,
|
||||
"created_at": time.Now(),
|
||||
"updated_at": plugin.UpdatedAt,
|
||||
}).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *pluginRepository) EntityName() string {
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
func (r *pluginRepository) NewInstance() any {
|
||||
return &model.Plugin{}
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Read(id string) (any, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Save(entity any) (string, error) {
|
||||
p := entity.(*model.Plugin)
|
||||
if !r.isPermitted() {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(p)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return p.ID, err
|
||||
}
|
||||
|
||||
func (r *pluginRepository) Update(id string, entity any, cols ...string) error {
|
||||
p := entity.(*model.Plugin)
|
||||
p.ID = id
|
||||
if !r.isPermitted() {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Put(p)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var _ model.PluginRepository = (*pluginRepository)(nil)
|
||||
var _ rest.Repository = (*pluginRepository)(nil)
|
||||
var _ rest.Persistable = (*pluginRepository)(nil)
|
||||
227
persistence/plugin_repository_test.go
Normal file
227
persistence/plugin_repository_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PluginRepository", func() {
|
||||
var repo model.PluginRepository
|
||||
|
||||
Describe("Admin User", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPluginRepository(ctx, GetDBXBuilder())
|
||||
|
||||
// Clean up any existing plugins
|
||||
all, _ := repo.GetAll()
|
||||
for _, p := range all {
|
||||
_ = repo.Delete(p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
// Clean up after tests
|
||||
all, _ := repo.GetAll()
|
||||
for _, p := range all {
|
||||
_ = repo.Delete(p.ID)
|
||||
}
|
||||
})
|
||||
|
||||
Describe("CountAll", func() {
|
||||
It("returns 0 when no plugins exist", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("returns the number of plugins in the DB", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"})
|
||||
_ = repo.Put(&model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"})
|
||||
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("deletes existing item", func() {
|
||||
plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"}
|
||||
_ = repo.Put(plugin)
|
||||
|
||||
err := repo.Delete(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
_, err = repo.Get(plugin.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing item", func() {
|
||||
plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"}
|
||||
_ = repo.Put(plugin)
|
||||
|
||||
res, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(res.ID).To(Equal(plugin.ID))
|
||||
Expect(res.Path).To(Equal(plugin.Path))
|
||||
Expect(res.Manifest).To(Equal(plugin.Manifest))
|
||||
})
|
||||
|
||||
It("errors when missing", func() {
|
||||
_, err := repo.Get("notanid")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all items from the DB", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"})
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"})
|
||||
|
||||
all, err := repo.GetAll()
|
||||
Expect(err).To(BeNil())
|
||||
Expect(all).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("supports pagination", func() {
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"})
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"})
|
||||
_ = repo.Put(&model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"})
|
||||
|
||||
page1, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 0, Sort: "id"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(page1).To(HaveLen(2))
|
||||
|
||||
page2, err := repo.GetAll(model.QueryOptions{Max: 2, Offset: 2, Sort: "id"})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(page2).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
It("successfully creates a new plugin", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "new-plugin",
|
||||
Path: "/plugins/new.wasm",
|
||||
Manifest: `{"name":"new","version":"1.0"}`,
|
||||
Config: `{"setting":"value"}`,
|
||||
SHA256: "sha256hash",
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
saved, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(saved.Path).To(Equal(plugin.Path))
|
||||
Expect(saved.Manifest).To(Equal(plugin.Manifest))
|
||||
Expect(saved.Config).To(Equal(plugin.Config))
|
||||
Expect(saved.Enabled).To(BeFalse())
|
||||
Expect(saved.CreatedAt).NotTo(BeZero())
|
||||
Expect(saved.UpdatedAt).NotTo(BeZero())
|
||||
})
|
||||
|
||||
It("successfully updates an existing plugin", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "update-plugin",
|
||||
Path: "/plugins/update.wasm",
|
||||
Manifest: `{"name":"test"}`,
|
||||
SHA256: "original",
|
||||
Enabled: false,
|
||||
}
|
||||
_ = repo.Put(plugin)
|
||||
|
||||
plugin.Enabled = true
|
||||
plugin.Config = `{"new":"config"}`
|
||||
plugin.SHA256 = "updated"
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
saved, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(saved.Enabled).To(BeTrue())
|
||||
Expect(saved.Config).To(Equal(`{"new":"config"}`))
|
||||
Expect(saved.SHA256).To(Equal("updated"))
|
||||
})
|
||||
|
||||
It("stores and retrieves last_error", func() {
|
||||
plugin := &model.Plugin{
|
||||
ID: "error-plugin",
|
||||
Path: "/plugins/error.wasm",
|
||||
Manifest: "{}",
|
||||
SHA256: "hash",
|
||||
LastError: "failed to load: missing export",
|
||||
}
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
saved, err := repo.Get(plugin.ID)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(saved.LastError).To(Equal("failed to load: missing export"))
|
||||
})
|
||||
|
||||
It("fails when ID is empty", func() {
|
||||
plugin := &model.Plugin{
|
||||
Path: "/plugins/noid.wasm",
|
||||
Manifest: "{}",
|
||||
SHA256: "hash",
|
||||
}
|
||||
err := repo.Put(plugin)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("ID cannot be empty"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular User", func() {
|
||||
BeforeEach(func() {
|
||||
ctx := GinkgoT().Context()
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false})
|
||||
repo = NewPluginRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("CountAll", func() {
|
||||
It("fails to count items", func() {
|
||||
_, err := repo.CountAll()
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("fails to delete items", func() {
|
||||
err := repo.Delete("any-id")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("fails to get items", func() {
|
||||
_, err := repo.Get("any-id")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("fails to get all items", func() {
|
||||
_, err := repo.GetAll()
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put", func() {
|
||||
It("fails to create/update item", func() {
|
||||
err := repo.Put(&model.Plugin{
|
||||
ID: "user-create",
|
||||
Path: "/plugins/create.wasm",
|
||||
Manifest: "{}",
|
||||
SHA256: "hash",
|
||||
})
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,8 +51,10 @@ func unmarshalParticipants(data string) (model.Participants, error) {
|
||||
}
|
||||
|
||||
func (r sqlRepository) updateParticipants(itemID string, participants model.Participants) error {
|
||||
ids := participants.AllIDs()
|
||||
sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}})
|
||||
// Delete all existing participant entries for this item.
|
||||
// This ensures stale role associations are removed when an artist's role changes
|
||||
// (e.g., an artist was both albumartist and composer, but is now only composer).
|
||||
sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID})
|
||||
_, err := r.executeSQL(sqd)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -340,7 +340,15 @@ func (r *userRepository) Delete(id string) error {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return rest.ErrNotFound
|
||||
}
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clean up orphaned plugin references for the deleted user
|
||||
if err := cleanupPluginUserReferences(r.db, id); err != nil {
|
||||
log.Error(r.ctx, "Failed to cleanup plugin user references", "userID", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func keyTo32Bytes(input string) []byte {
|
||||
|
||||
4
plugins/.gitignore
vendored
Normal file
4
plugins/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# Rust build artifacts
|
||||
# Cargo.lock is not needed for library crates (this is a cdylib)
|
||||
Cargo.lock
|
||||
target
|
||||
2482
plugins/README.md
2482
plugins/README.md
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin
|
||||
func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmMediaAgent{
|
||||
baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityMetadataAgent,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface
|
||||
type wasmMediaAgent struct {
|
||||
*baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) AgentName() string {
|
||||
return w.id
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) mapError(err error) error {
|
||||
if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) {
|
||||
return agents.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Album-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) {
|
||||
return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
if res == nil || res.Info == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
info := res.Info
|
||||
return &agents.AlbumInfo{
|
||||
Name: info.Name,
|
||||
MBID: info.Mbid,
|
||||
Description: info.Description,
|
||||
URL: info.Url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) {
|
||||
return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(res.Images), nil
|
||||
}
|
||||
|
||||
// Artist-related methods
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) {
|
||||
return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetMbid(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) {
|
||||
return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetUrl(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) {
|
||||
return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return "", w.mapError(err)
|
||||
}
|
||||
return res.GetBiography(), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) {
|
||||
return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
artists := make([]agents.Artist, 0, len(resp.GetArtists()))
|
||||
for _, a := range resp.GetArtists() {
|
||||
artists = append(artists, agents.Artist{
|
||||
Name: a.GetName(),
|
||||
MBID: a.GetMbid(),
|
||||
})
|
||||
}
|
||||
return artists, nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) {
|
||||
return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
return convertExternalImages(resp.Images), nil
|
||||
}
|
||||
|
||||
func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) {
|
||||
return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, w.mapError(err)
|
||||
}
|
||||
songs := make([]agents.Song, 0, len(resp.GetSongs()))
|
||||
for _, s := range resp.GetSongs() {
|
||||
songs = append(songs, agents.Song{
|
||||
Name: s.GetName(),
|
||||
MBID: s.GetMbid(),
|
||||
})
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
// Helper function to convert ExternalImage objects from the API to the agents package
|
||||
func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage {
|
||||
result := make([]agents.ExternalImage, 0, len(images))
|
||||
for _, img := range images {
|
||||
result = append(result, agents.ExternalImage{
|
||||
URL: img.GetUrl(),
|
||||
Size: int(img.GetSize()),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Adapter Media Agent", func() {
|
||||
var ctx context.Context
|
||||
var mgr *managerImpl
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
// Ensure plugins folder is set to testdata
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Folder = testDataDir
|
||||
conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
|
||||
|
||||
mgr = createManager(nil, metrics.NewNoopInstance())
|
||||
mgr.ScanPlugins()
|
||||
|
||||
// Wait for all plugins to compile to avoid race conditions
|
||||
err := mgr.EnsureCompiled("multi_plugin")
|
||||
Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully")
|
||||
err = mgr.EnsureCompiled("fake_album_agent")
|
||||
Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully")
|
||||
})
|
||||
|
||||
Describe("AgentName and PluginName", func() {
|
||||
It("should return the plugin name", func() {
|
||||
agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent")
|
||||
Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded")
|
||||
Expect(agent.PluginID()).To(Equal("multi_plugin"))
|
||||
})
|
||||
It("should return the agent name", func() {
|
||||
agent, ok := mgr.LoadMediaAgent("multi_plugin")
|
||||
Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent")
|
||||
Expect(agent.AgentName()).To(Equal("multi_plugin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_album_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_album_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetAlbumInfo", func() {
|
||||
It("should return album information", func() {
|
||||
info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(info).NotTo(BeNil())
|
||||
Expect(info.Name).To(Equal("Test Album"))
|
||||
Expect(info.MBID).To(Equal("album-mbid-123"))
|
||||
Expect(info.Description).To(Equal("This is a test album description"))
|
||||
Expect(info.URL).To(Equal("https://example.com/album"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns nil response", func() {
|
||||
_, err := agent.GetAlbumInfo(ctx, "", "", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetAlbumImages", func() {
|
||||
It("should return album images", func() {
|
||||
images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/album1.jpg", Size: 300},
|
||||
{URL: "https://example.com/album2.jpg", Size: 400},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Artist methods", func() {
|
||||
var agent *wasmMediaAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
a, ok := mgr.LoadMediaAgent("fake_artist_agent")
|
||||
Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded")
|
||||
agent = a.(*wasmMediaAgent)
|
||||
})
|
||||
|
||||
Context("GetArtistMBID", func() {
|
||||
It("should return artist MBID", func() {
|
||||
mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mbid).To(Equal("1234567890"))
|
||||
})
|
||||
|
||||
It("should return ErrNotFound when plugin returns not found", func() {
|
||||
_, err := agent.GetArtistMBID(ctx, "artist-id", "")
|
||||
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistURL", func() {
|
||||
It("should return artist URL", func() {
|
||||
url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(url).To(Equal("https://example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistBiography", func() {
|
||||
It("should return artist biography", func() {
|
||||
bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(bio).To(Equal("This is a test biography"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetSimilarArtists", func() {
|
||||
It("should return similar artists", func() {
|
||||
artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(artists).To(Equal([]agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistImages", func() {
|
||||
It("should return artist images", func() {
|
||||
images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(images).To(Equal([]agents.ExternalImage{
|
||||
{URL: "https://example.com/image1.jpg", Size: 100},
|
||||
{URL: "https://example.com/image2.jpg", Size: 200},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GetArtistTopSongs", func() {
|
||||
It("should return artist top songs", func() {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(songs).To(Equal([]agents.Song{
|
||||
{Name: "Song 1", MBID: "mbid1"},
|
||||
{Name: "Song 2", MBID: "mbid2"},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Helper functions", func() {
|
||||
It("convertExternalImages should convert API image objects to agent image objects", func() {
|
||||
apiImages := []*api.ExternalImage{
|
||||
{Url: "https://example.com/image1.jpg", Size: 100},
|
||||
{Url: "https://example.com/image2.jpg", Size: 200},
|
||||
}
|
||||
|
||||
agentImages := convertExternalImages(apiImages)
|
||||
Expect(agentImages).To(HaveLen(2))
|
||||
|
||||
for i, img := range agentImages {
|
||||
Expect(img.URL).To(Equal(apiImages[i].Url))
|
||||
Expect(img.Size).To(Equal(int(apiImages[i].Size)))
|
||||
}
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle empty slice", func() {
|
||||
agentImages := convertExternalImages([]*api.ExternalImage{})
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("convertExternalImages should handle nil", func() {
|
||||
agentImages := convertExternalImages(nil)
|
||||
Expect(agentImages).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error mapping", func() {
|
||||
var agent wasmMediaAgent
|
||||
|
||||
It("should map API ErrNotFound to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotFound)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should map API ErrNotImplemented to agents.ErrNotFound", func() {
|
||||
err := agent.mapError(api.ErrNotImplemented)
|
||||
Expect(err).To(Equal(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
It("should pass through other errors", func() {
|
||||
testErr := errors.New("test error")
|
||||
err := agent.mapError(testErr)
|
||||
Expect(err).To(Equal(testErr))
|
||||
})
|
||||
|
||||
It("should handle nil error", func() {
|
||||
err := agent.mapError(nil)
|
||||
Expect(err).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin
|
||||
func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmSchedulerCallback{
|
||||
baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilitySchedulerCallback,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmSchedulerCallback adapts a SchedulerCallback plugin
|
||||
type wasmSchedulerCallback struct {
|
||||
*baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error {
|
||||
_, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) {
|
||||
return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{
|
||||
ScheduleId: scheduleID,
|
||||
Payload: payload,
|
||||
IsRecurring: isRecurring,
|
||||
})
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmScrobblerPlugin{
|
||||
baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityScrobbler,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
type wasmScrobblerPlugin struct {
|
||||
*baseCapability[api.Scrobbler, *api.ScrobblerPlugin]
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) {
|
||||
return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err)
|
||||
}
|
||||
return err == nil && resp.Authorized
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
|
||||
trackInfo := w.toTrackInfo(track, position)
|
||||
_, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: time.Now().Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
||||
username, _ := request.UsernameFrom(ctx)
|
||||
if username == "" {
|
||||
u, ok := request.UserFrom(ctx)
|
||||
if ok {
|
||||
username = u.UserName
|
||||
}
|
||||
}
|
||||
trackInfo := w.toTrackInfo(&s.MediaFile, 0)
|
||||
_, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) {
|
||||
resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
Track: trackInfo,
|
||||
Timestamp: s.TimeStamp.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return struct{}{}, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return struct{}{}, nil
|
||||
}
|
||||
return struct{}{}, nil
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo {
|
||||
artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist]))
|
||||
|
||||
for _, a := range track.Participants[model.RoleArtist] {
|
||||
artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist]))
|
||||
for _, a := range track.Participants[model.RoleAlbumArtist] {
|
||||
albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID})
|
||||
}
|
||||
trackInfo := &api.TrackInfo{
|
||||
Id: track.ID,
|
||||
Mbid: track.MbzRecordingID,
|
||||
Name: track.Title,
|
||||
Album: track.Album,
|
||||
AlbumMbid: track.MbzAlbumID,
|
||||
Artists: artists,
|
||||
AlbumArtists: albumArtists,
|
||||
Length: int32(track.Duration),
|
||||
Position: int32(position),
|
||||
}
|
||||
return trackInfo
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/plugins/api"
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin
|
||||
func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin {
|
||||
loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc))
|
||||
if err != nil {
|
||||
log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err)
|
||||
return nil
|
||||
}
|
||||
return &wasmWebSocketCallback{
|
||||
baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin](
|
||||
wasmPath,
|
||||
pluginID,
|
||||
CapabilityWebSocketCallback,
|
||||
m.metrics,
|
||||
loader,
|
||||
func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) {
|
||||
return l.Load(ctx, path)
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// wasmWebSocketCallback adapts a WebSocketCallback plugin
|
||||
type wasmWebSocketCallback struct {
|
||||
*baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package api;
|
||||
|
||||
option go_package = "github.com/navidrome/navidrome/plugins/api;api";
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service MetadataAgent {
|
||||
// Artist metadata methods
|
||||
rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse);
|
||||
rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse);
|
||||
rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse);
|
||||
rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse);
|
||||
rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse);
|
||||
rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse);
|
||||
|
||||
// Album metadata methods
|
||||
rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse);
|
||||
rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse);
|
||||
}
|
||||
|
||||
message ArtistMBIDRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message ArtistMBIDResponse {
|
||||
string mbid = 1;
|
||||
}
|
||||
|
||||
message ArtistURLRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ArtistURLResponse {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message ArtistBiographyRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ArtistBiographyResponse {
|
||||
string biography = 1;
|
||||
}
|
||||
|
||||
message ArtistSimilarRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
int32 limit = 4;
|
||||
}
|
||||
|
||||
message Artist {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
}
|
||||
|
||||
message ArtistSimilarResponse {
|
||||
repeated Artist artists = 1;
|
||||
}
|
||||
|
||||
message ArtistImageRequest {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message ExternalImage {
|
||||
string url = 1;
|
||||
int32 size = 2;
|
||||
}
|
||||
|
||||
message ArtistImageResponse {
|
||||
repeated ExternalImage images = 1;
|
||||
}
|
||||
|
||||
message ArtistTopSongsRequest {
|
||||
string id = 1;
|
||||
string artistName = 2;
|
||||
string mbid = 3;
|
||||
int32 count = 4;
|
||||
}
|
||||
|
||||
message Song {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
}
|
||||
|
||||
message ArtistTopSongsResponse {
|
||||
repeated Song songs = 1;
|
||||
}
|
||||
|
||||
message AlbumInfoRequest {
|
||||
string name = 1;
|
||||
string artist = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message AlbumInfo {
|
||||
string name = 1;
|
||||
string mbid = 2;
|
||||
string description = 3;
|
||||
string url = 4;
|
||||
}
|
||||
|
||||
message AlbumInfoResponse {
|
||||
AlbumInfo info = 1;
|
||||
}
|
||||
|
||||
message AlbumImagesRequest {
|
||||
string name = 1;
|
||||
string artist = 2;
|
||||
string mbid = 3;
|
||||
}
|
||||
|
||||
message AlbumImagesResponse {
|
||||
repeated ExternalImage images = 1;
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service Scrobbler {
|
||||
rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse);
|
||||
rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse);
|
||||
rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse);
|
||||
}
|
||||
|
||||
message ScrobblerIsAuthorizedRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
}
|
||||
|
||||
message ScrobblerIsAuthorizedResponse {
|
||||
bool authorized = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message TrackInfo {
|
||||
string id = 1;
|
||||
string mbid = 2;
|
||||
string name = 3;
|
||||
string album = 4;
|
||||
string album_mbid = 5;
|
||||
repeated Artist artists = 6;
|
||||
repeated Artist album_artists = 7;
|
||||
int32 length = 8; // seconds
|
||||
int32 position = 9; // seconds
|
||||
}
|
||||
|
||||
message ScrobblerNowPlayingRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
TrackInfo track = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message ScrobblerNowPlayingResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
message ScrobblerScrobbleRequest {
|
||||
string user_id = 1;
|
||||
string username = 2;
|
||||
TrackInfo track = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
|
||||
message ScrobblerScrobbleResponse {
|
||||
string error = 1;
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service SchedulerCallback {
|
||||
rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse);
|
||||
}
|
||||
|
||||
message SchedulerCallbackRequest {
|
||||
string schedule_id = 1; // ID of the scheduled job that triggered this callback
|
||||
bytes payload = 2; // The data passed when the job was scheduled
|
||||
bool is_recurring = 3; // Whether this is from a recurring schedule (cron job)
|
||||
}
|
||||
|
||||
message SchedulerCallbackResponse {
|
||||
string error = 1; // Error message if the callback failed
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service LifecycleManagement {
|
||||
rpc OnInit(InitRequest) returns (InitResponse);
|
||||
}
|
||||
|
||||
message InitRequest {
|
||||
map<string, string> config = 1; // Configuration specific to this plugin
|
||||
}
|
||||
|
||||
message InitResponse {
|
||||
string error = 1; // Error message if initialization failed
|
||||
}
|
||||
|
||||
// go:plugin type=plugin version=1
|
||||
service WebSocketCallback {
|
||||
// Called when a text message is received
|
||||
rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse);
|
||||
|
||||
// Called when a binary message is received
|
||||
rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse);
|
||||
|
||||
// Called when an error occurs
|
||||
rpc OnError(OnErrorRequest) returns (OnErrorResponse);
|
||||
|
||||
// Called when the connection is closed
|
||||
rpc OnClose(OnCloseRequest) returns (OnCloseResponse);
|
||||
}
|
||||
|
||||
message OnTextMessageRequest {
|
||||
string connection_id = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message OnTextMessageResponse {}
|
||||
|
||||
message OnBinaryMessageRequest {
|
||||
string connection_id = 1;
|
||||
bytes data = 2;
|
||||
}
|
||||
|
||||
message OnBinaryMessageResponse {}
|
||||
|
||||
message OnErrorRequest {
|
||||
string connection_id = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message OnErrorResponse {}
|
||||
|
||||
message OnCloseRequest {
|
||||
string connection_id = 1;
|
||||
int32 code = 2;
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
message OnCloseResponse {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
//go:build !wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wazero "github.com/tetratelabs/wazero"
|
||||
wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
|
||||
)
|
||||
|
||||
type wazeroConfigOption func(plugin *WazeroConfig)
|
||||
|
||||
type WazeroNewRuntime func(context.Context) (wazero.Runtime, error)
|
||||
|
||||
type WazeroConfig struct {
|
||||
newRuntime func(context.Context) (wazero.Runtime, error)
|
||||
moduleConfig wazero.ModuleConfig
|
||||
}
|
||||
|
||||
func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.newRuntime = newRuntime
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultWazeroRuntime() WazeroNewRuntime {
|
||||
return func(ctx context.Context) (wazero.Runtime, error) {
|
||||
r := wazero.NewRuntime(ctx)
|
||||
if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
|
||||
func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption {
|
||||
return func(h *WazeroConfig) {
|
||||
h.moduleConfig = moduleConfig
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
//go:build wasip1
|
||||
|
||||
// Code generated by protoc-gen-go-plugin. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go-plugin v0.1.0
|
||||
// protoc v5.29.3
|
||||
// source: api/api.proto
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
context "context"
|
||||
wasm "github.com/knqyf263/go-plugin/wasm"
|
||||
)
|
||||
|
||||
const MetadataAgentPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport metadata_agent_api_version
|
||||
func _metadata_agent_api_version() uint64 {
|
||||
return MetadataAgentPluginAPIVersion
|
||||
}
|
||||
|
||||
var metadataAgent MetadataAgent
|
||||
|
||||
func RegisterMetadataAgent(p MetadataAgent) {
|
||||
metadataAgent = p
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_mbid
|
||||
func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistMBIDRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistMBID(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_url
|
||||
func _metadata_agent_get_artist_url(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistURLRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistURL(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_biography
|
||||
func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistBiographyRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistBiography(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_similar_artists
|
||||
func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistSimilarRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetSimilarArtists(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_images
|
||||
func _metadata_agent_get_artist_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistImageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_artist_top_songs
|
||||
func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ArtistTopSongsRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetArtistTopSongs(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_info
|
||||
func _metadata_agent_get_album_info(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumInfoRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumInfo(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport metadata_agent_get_album_images
|
||||
func _metadata_agent_get_album_images(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(AlbumImagesRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := metadataAgent.GetAlbumImages(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const ScrobblerPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scrobbler_api_version
|
||||
func _scrobbler_api_version() uint64 {
|
||||
return ScrobblerPluginAPIVersion
|
||||
}
|
||||
|
||||
var scrobbler Scrobbler
|
||||
|
||||
func RegisterScrobbler(p Scrobbler) {
|
||||
scrobbler = p
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_is_authorized
|
||||
func _scrobbler_is_authorized(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerIsAuthorizedRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.IsAuthorized(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_now_playing
|
||||
func _scrobbler_now_playing(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerNowPlayingRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.NowPlaying(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport scrobbler_scrobble
|
||||
func _scrobbler_scrobble(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(ScrobblerScrobbleRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := scrobbler.Scrobble(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const SchedulerCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport scheduler_callback_api_version
|
||||
func _scheduler_callback_api_version() uint64 {
|
||||
return SchedulerCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var schedulerCallback SchedulerCallback
|
||||
|
||||
func RegisterSchedulerCallback(p SchedulerCallback) {
|
||||
schedulerCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport scheduler_callback_on_scheduler_callback
|
||||
func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(SchedulerCallbackRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const LifecycleManagementPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport lifecycle_management_api_version
|
||||
func _lifecycle_management_api_version() uint64 {
|
||||
return LifecycleManagementPluginAPIVersion
|
||||
}
|
||||
|
||||
var lifecycleManagement LifecycleManagement
|
||||
|
||||
func RegisterLifecycleManagement(p LifecycleManagement) {
|
||||
lifecycleManagement = p
|
||||
}
|
||||
|
||||
//go:wasmexport lifecycle_management_on_init
|
||||
func _lifecycle_management_on_init(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(InitRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := lifecycleManagement.OnInit(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
const WebSocketCallbackPluginAPIVersion = 1
|
||||
|
||||
//go:wasmexport web_socket_callback_api_version
|
||||
func _web_socket_callback_api_version() uint64 {
|
||||
return WebSocketCallbackPluginAPIVersion
|
||||
}
|
||||
|
||||
var webSocketCallback WebSocketCallback
|
||||
|
||||
func RegisterWebSocketCallback(p WebSocketCallback) {
|
||||
webSocketCallback = p
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_text_message
|
||||
func _web_socket_callback_on_text_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnTextMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnTextMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_binary_message
|
||||
func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnBinaryMessageRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnBinaryMessage(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_error
|
||||
func _web_socket_callback_on_error(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnErrorRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnError(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
|
||||
//go:wasmexport web_socket_callback_on_close
|
||||
func _web_socket_callback_on_close(ptr, size uint32) uint64 {
|
||||
b := wasm.PtrToByte(ptr, size)
|
||||
req := new(OnCloseRequest)
|
||||
if err := req.UnmarshalVT(b); err != nil {
|
||||
return 0
|
||||
}
|
||||
response, err := webSocketCallback.OnClose(context.Background(), req)
|
||||
if err != nil {
|
||||
ptr, size = wasm.ByteToPtr([]byte(err.Error()))
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size) |
|
||||
// Indicate that this is the error string by setting the 32-th bit, assuming that
|
||||
// no data exceeds 31-bit size (2 GiB).
|
||||
(1 << 31)
|
||||
}
|
||||
|
||||
b, err = response.MarshalVT()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
ptr, size = wasm.ByteToPtr(b)
|
||||
return (uint64(ptr) << uint64(32)) | uint64(size)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//go:build !wasip1
|
||||
|
||||
package api
|
||||
|
||||
import "github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
|
||||
// This file exists to provide stubs for the plugin registration functions when building for non-WASM targets.
|
||||
// This is useful for testing and development purposes, as it allows you to build and run your plugin code
|
||||
// without having to compile it to WASM.
|
||||
// In a real-world scenario, you would compile your plugin to WASM and use the generated registration functions.
|
||||
|
||||
func RegisterMetadataAgent(MetadataAgent) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterScrobbler(Scrobbler) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterSchedulerCallback(SchedulerCallback) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterLifecycleManagement(LifecycleManagement) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterWebSocketCallback(WebSocketCallback) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
|
||||
panic("not implemented")
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
//go:build wasip1
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/host/scheduler"
|
||||
)
|
||||
|
||||
var callbacks = make(namedCallbacks)
|
||||
|
||||
// RegisterNamedSchedulerCallback registers a named scheduler callback. Named callbacks allow multiple callbacks to be registered
|
||||
// within the same plugin, and for the schedules to be scoped to the named callback. If you only need a single callback, you can use
|
||||
// the default (unnamed) callback registration function, RegisterSchedulerCallback.
|
||||
// It returns a scheduler.SchedulerService that can be used to schedule jobs for the named callback.
|
||||
//
|
||||
// Notes:
|
||||
//
|
||||
// - You can't mix named and unnamed callbacks within the same plugin.
|
||||
// - The name should be unique within the plugin, and it's recommended to use a short, descriptive name.
|
||||
// - The name is case-sensitive.
|
||||
func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService {
|
||||
callbacks[name] = cb
|
||||
RegisterSchedulerCallback(&callbacks)
|
||||
return &namedSchedulerService{name: name, svc: scheduler.NewSchedulerService()}
|
||||
}
|
||||
|
||||
const zwsp = string('\u200b')
|
||||
|
||||
// namedCallbacks is a map of named scheduler callbacks. The key is the name of the callback, and the value is the callback itself.
|
||||
type namedCallbacks map[string]SchedulerCallback
|
||||
|
||||
func parseKey(key string) (string, string) {
|
||||
parts := strings.SplitN(key, zwsp, 2)
|
||||
if len(parts) != 2 {
|
||||
return "", ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
|
||||
func (n *namedCallbacks) OnSchedulerCallback(ctx context.Context, req *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) {
|
||||
name, scheduleId := parseKey(req.ScheduleId)
|
||||
cb, exists := callbacks[name]
|
||||
if !exists {
|
||||
return nil, nil
|
||||
}
|
||||
req.ScheduleId = scheduleId
|
||||
return cb.OnSchedulerCallback(ctx, req)
|
||||
}
|
||||
|
||||
// namedSchedulerService is a wrapper around the host scheduler service that prefixes the schedule IDs with the
|
||||
// callback name. It is returned by RegisterNamedSchedulerCallback, and should be used by the plugin to schedule
|
||||
// jobs for the named callback.
|
||||
type namedSchedulerService struct {
|
||||
name string
|
||||
cb SchedulerCallback
|
||||
svc scheduler.SchedulerService
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) makeKey(id string) string {
|
||||
return n.name + zwsp + id
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) mapResponse(resp *scheduler.ScheduleResponse, err error) (*scheduler.ScheduleResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, resp.ScheduleId = parseKey(resp.ScheduleId)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) ScheduleOneTime(ctx context.Context, request *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.mapResponse(n.svc.ScheduleOneTime(ctx, request))
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) ScheduleRecurring(ctx context.Context, request *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.mapResponse(n.svc.ScheduleRecurring(ctx, request))
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *scheduler.CancelRequest) (*scheduler.CancelResponse, error) {
|
||||
key := n.makeKey(request.ScheduleId)
|
||||
request.ScheduleId = key
|
||||
return n.svc.CancelSchedule(ctx, request)
|
||||
}
|
||||
|
||||
func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) {
|
||||
return n.svc.TimeNow(ctx, request)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user