diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 64a01c9ae..451ffb2bd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,18 +36,18 @@ This is a music streaming server written in Go with a React frontend. The applic 5. Document configuration options in code 6. Consider performance implications when working with music libraries 7. Follow existing error handling patterns -8. Ensure compatibility with external services (LastFM, Spotify) +8. Ensure compatibility with external services (LastFM, Spotify, Deezer) ## Development Workflow - Test changes thoroughly, especially around concurrent operations - Validate both backend and frontend interactions - Consider how changes will affect user experience and performance - Test with different music library sizes and configurations -- Always run formatting and linting before committing changes +- Before committing, ALWAYS run `make format lint test`, and make sure there are no issues ## Important commands - `make build`: Build the application - `make test`: Run Go tests - To run tests for a specific package, use `make test PKG=./pkgname/...` - `make lintall`: Run linters -- `make format`: Format code \ No newline at end of file +- `make format`: Format code diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..10431e909 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ +### Description + + +### Related Issues + + +### Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Refactor +- [ ] Other (please describe): + +### Checklist +Please review and check all that apply: + +- [ ] My code follows the project’s coding style +- [ ] I have tested the changes locally +- [ ] I have added or updated documentation as needed +- [ ] I have added tests that prove my fix/feature works (or explain why not) +- [ ] All existing and new tests pass + +### How to Test + + +### Screenshots / Demos (if applicable) + + +### Additional Notes + + + \ No newline at end of file diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c20e8e0cd..8938c0803 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true env: - CROSS_TAGLIB_VERSION: "2.0.2-1" + CROSS_TAGLIB_VERSION: "2.1.1-1" IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} jobs: @@ -71,14 +71,14 @@ jobs: version: ${{ env.CROSS_TAGLIB_VERSION }} - name: golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v8 with: version: latest problem-matchers: true args: --timeout 2m - name: Run go goimports - run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'` + run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'` - run: go mod tidy - name: Verify no changes from goimports and go mod tidy run: | diff --git a/.gitignore b/.gitignore index 4e32e14fd..74d7ee46f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /navidrome /iTunes*.xml /tmp +/bin data/* vendor/*/ wiki @@ -23,7 +24,11 @@ music docker-compose.yml !contrib/docker-compose.yml binaries -navidrome-master +navidrome-* AGENTS.md +.github/prompts +.github/instructions +.github/git-commit-instructions.md *.exe -bin/ \ No newline at end of file +*.test +*.wasm \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4b4c3d18c..3600ff6d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (orignal image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS xx-build +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build # v1.5.0 ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a @@ -26,9 +26,9 @@ COPY --from=xx-build /out/ /usr/bin/ ######################################################################################################################## ### Get TagLib -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build ARG TARGETPLATFORM -ARG CROSS_TAGLIB_VERSION=2.0.2-1 +ARG CROSS_TAGLIB_VERSION=2.1.1-1 ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ RUN < /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6) + @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6) .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code @@ -64,7 +64,7 @@ lintall: lint ##@Development Lint Go and JS code format: ##@Development Format code @(cd ./ui && npm run prettier) - @go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$` + @go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$ | grep -v .pb.go$$` @go mod tidy .PHONY: format @@ -153,6 +153,20 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows @du -h binaries/msi/*.msi .PHONY: docker-msi +run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag= + @if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag="; exit 1; fi + @TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \ + VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \ + if [ -f navidrome.toml ]; then \ + VOLUMES="$$VOLUMES -v $(PWD)/navidrome.toml:/data/navidrome.toml:ro"; \ + MUSIC_FOLDER=$$(grep '^MusicFolder' navidrome.toml | head -n1 | sed 's/.*= *"//' | sed 's/".*//'); \ + if [ -n "$$MUSIC_FOLDER" ] && [ -d "$$MUSIC_FOLDER" ]; then \ + VOLUMES="$$VOLUMES -v $$MUSIC_FOLDER:/music:ro"; \ + fi; \ + fi; \ + echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag) +.PHONY: run-docker + package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms @if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot @@ -221,6 +235,24 @@ 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 = \ diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go index 08fc1a506..0b5126542 100644 --- a/adapters/taglib/end_to_end_test.go +++ b/adapters/taglib/end_to_end_test.go @@ -8,6 +8,7 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -82,6 +83,29 @@ var _ = Describe("Extractor", func() { e = &extractor{} }) + Describe("ReplayGain", func() { + DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { + path := "tests/fixtures/" + file + mds, err := e.Parse(path) + Expect(err).ToNot(HaveOccurred()) + + info := mds[path] + fileInfo, _ := os.Stat(path) + info.FileInfo = testFileInfo{FileInfo: fileInfo} + + metadata := metadata.New(path, info) + mf := metadata.ToMediaFile(1, "folderID") + + Expect(mf.RGTrackGain).To(Equal(trackGain)) + Expect(mf.RGTrackPeak).To(Equal(trackPeak)) + Expect(mf.RGAlbumGain).To(Equal(albumGain)) + Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) + }, + Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + ) + }) + Describe("Participants", func() { DescribeTable("test tags consistent across formats", func(format string) { path := "tests/fixtures/test." + format diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go index c89dabf62..62a949d85 100644 --- a/adapters/taglib/taglib.go +++ b/adapters/taglib/taglib.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/storage/local" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/metadata" @@ -148,4 +149,7 @@ func init() { // ignores fs, as taglib extractor only works with local files return &extractor{baseDir} }) + conf.AddHook(func() { + log.Debug("TagLib version", "version", Version()) + }) } diff --git a/cmd/cmd_suite_test.go b/cmd/cmd_suite_test.go new file mode 100644 index 000000000..f2ddf6a9c --- /dev/null +++ b/cmd/cmd_suite_test.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCmd(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Cmd Suite") +} diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 000000000..0f3b66078 --- /dev/null +++ b/cmd/plugin.go @@ -0,0 +1,716 @@ +package cmd + +import ( + "cmp" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/spf13/cobra" +) + +const ( + pluginPackageExtension = ".ndp" + pluginDirPermissions = 0700 + pluginFilePermissions = 0600 +) + +func init() { + pluginCmd := &cobra.Command{ + Use: "plugin", + Short: "Manage Navidrome plugins", + Long: "Commands for managing Navidrome plugins", + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Long: "List all installed plugins with their metadata", + Run: pluginList, + } + + infoCmd := &cobra.Command{ + Use: "info [pluginPackage|pluginName]", + Short: "Show details of a plugin", + Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin", + Args: cobra.ExactArgs(1), + Run: pluginInfo, + } + + installCmd := &cobra.Command{ + Use: "install [pluginPackage]", + Short: "Install a plugin from a .ndp file", + Long: "Install a Navidrome Plugin Package (.ndp) file", + Args: cobra.ExactArgs(1), + Run: pluginInstall, + } + + removeCmd := &cobra.Command{ + Use: "remove [pluginName]", + Short: "Remove an installed plugin", + Long: "Remove a plugin by name", + Args: cobra.ExactArgs(1), + Run: pluginRemove, + } + + updateCmd := &cobra.Command{ + Use: "update [pluginPackage]", + Short: "Update an existing plugin", + Long: "Update an installed plugin with a new version from a .ndp file", + Args: cobra.ExactArgs(1), + Run: pluginUpdate, + } + + refreshCmd := &cobra.Command{ + Use: "refresh [pluginName]", + Short: "Reload a plugin without restarting Navidrome", + Long: "Reload and recompile a plugin without needing to restart Navidrome", + Args: cobra.ExactArgs(1), + Run: pluginRefresh, + } + + devCmd := &cobra.Command{ + Use: "dev [folder_path]", + Short: "Create symlink to development folder", + Long: "Create a symlink from a plugin development folder to the plugins directory for easier development", + Args: cobra.ExactArgs(1), + Run: pluginDev, + } + + pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd) + rootCmd.AddCommand(pluginCmd) +} + +// Validation helpers + +func validatePluginPackageFile(path string) error { + if !utils.FileExists(path) { + return fmt.Errorf("plugin package not found: %s", path) + } + if filepath.Ext(path) != pluginPackageExtension { + return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension) + } + return nil +} + +func validatePluginDirectory(pluginsDir, pluginName string) (string, error) { + pluginDir := filepath.Join(pluginsDir, pluginName) + if !utils.FileExists(pluginDir) { + return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir) + } + return pluginDir, nil +} + +func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) { + // Check if it's a directory or a symlink + lstat, err := os.Lstat(pluginDir) + if err != nil { + return "", false, fmt.Errorf("failed to stat plugin: %w", err) + } + + isSymlink = lstat.Mode()&os.ModeSymlink != 0 + + if isSymlink { + // Resolve the symlink target + targetDir, err := os.Readlink(pluginDir) + if err != nil { + return "", true, fmt.Errorf("failed to resolve symlink: %w", err) + } + + // If target is a relative path, make it absolute + if !filepath.IsAbs(targetDir) { + targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir) + } + + // Verify the target exists and is a directory + targetInfo, err := os.Stat(targetDir) + if err != nil { + return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err) + } + + if !targetInfo.IsDir() { + return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir) + } + + return targetDir, true, nil + } else if !lstat.IsDir() { + return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir) + } + + return pluginDir, false, nil +} + +// Package handling helpers + +func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) { + if err := validatePluginPackageFile(ndpPath); err != nil { + return nil, err + } + + pkg, err := plugins.LoadPackage(ndpPath) + if err != nil { + return nil, fmt.Errorf("failed to load plugin package: %w", err) + } + + return pkg, nil +} + +func extractAndSetupPlugin(ndpPath, targetDir string) error { + if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil { + return fmt.Errorf("failed to extract plugin package: %w", err) + } + + ensurePluginDirPermissions(targetDir) + return nil +} + +// Display helpers + +func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) { + if discovery.Error != nil { + // Handle global errors (like directory read failure) + if discovery.ID == "" { + log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error) + return + } + // Handle individual plugin errors - show them in the table + fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error) + return + } + + // Mark symlinks with an indicator + nameDisplay := discovery.Manifest.Name + if discovery.IsSymlink { + nameDisplay = nameDisplay + " (dev)" + } + + // Convert capabilities to strings + capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { + return string(cap) + }) + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + discovery.ID, + nameDisplay, + cmp.Or(discovery.Manifest.Author, "-"), + cmp.Or(discovery.Manifest.Version, "-"), + strings.Join(capabilities, ", "), + cmp.Or(discovery.Manifest.Description, "-")) +} + +func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) { + if permissions.Http != nil { + fmt.Printf("%shttp:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason) + fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork) + fmt.Printf("%s Allowed URLs:\n", indent) + for urlPattern, methodEnums := range permissions.Http.AllowedUrls { + methods := make([]string, len(methodEnums)) + for i, methodEnum := range methodEnums { + methods[i] = string(methodEnum) + } + fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", ")) + } + fmt.Println() + } + + if permissions.Config != nil { + fmt.Printf("%sconfig:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason) + fmt.Println() + } + + if permissions.Scheduler != nil { + fmt.Printf("%sscheduler:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason) + fmt.Println() + } + + if permissions.Websocket != nil { + fmt.Printf("%swebsocket:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason) + fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork) + fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", ")) + fmt.Println() + } + + if permissions.Cache != nil { + fmt.Printf("%scache:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason) + fmt.Println() + } + + if permissions.Artwork != nil { + fmt.Printf("%sartwork:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason) + fmt.Println() + } + + if permissions.Subsonicapi != nil { + allowedUsers := "All Users" + if len(permissions.Subsonicapi.AllowedUsernames) > 0 { + allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ") + } + fmt.Printf("%ssubsonicapi:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason) + fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins) + fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers) + fmt.Println() + } +} + +func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) { + fmt.Println("\nPlugin Information:") + fmt.Printf(" Name: %s\n", manifest.Name) + fmt.Printf(" Author: %s\n", manifest.Author) + fmt.Printf(" Version: %s\n", manifest.Version) + fmt.Printf(" Description: %s\n", manifest.Description) + + fmt.Print(" Capabilities: ") + capabilities := make([]string, len(manifest.Capabilities)) + for i, cap := range manifest.Capabilities { + capabilities[i] = string(cap) + } + fmt.Print(strings.Join(capabilities, ", ")) + fmt.Println() + + // Display manifest permissions using the typed permissions + fmt.Println(" Required Permissions:") + displayTypedPermissions(manifest.Permissions, " ") + + // Print file information if available + if fileInfo != nil { + fmt.Println("Package Information:") + fmt.Printf(" File: %s\n", fileInfo.path) + fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024) + fmt.Printf(" SHA-256: %s\n", fileInfo.hash) + fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339)) + } + + // Print file permissions information if available + if permInfo != nil { + fmt.Println("File Permissions:") + fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode) + if permInfo.isSymlink { + fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode) + } + fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode) + if permInfo.wasmMode != "" { + fmt.Printf(" WASM File: %s\n", permInfo.wasmMode) + } + } +} + +type pluginFileInfo struct { + path string + size int64 + hash string + modTime time.Time +} + +type pluginPermissionInfo struct { + dirPath string + dirMode string + isSymlink bool + targetPath string + targetMode string + manifestMode string + wasmMode string +} + +func getFileInfo(path string) *pluginFileInfo { + fileInfo, err := os.Stat(path) + if err != nil { + log.Error("Failed to get file information", err) + return nil + } + + return &pluginFileInfo{ + path: path, + size: fileInfo.Size(), + hash: calculateSHA256(path), + modTime: fileInfo.ModTime(), + } +} + +func getPermissionInfo(pluginDir string) *pluginPermissionInfo { + // Get plugin directory permissions + dirInfo, err := os.Lstat(pluginDir) + if err != nil { + log.Error("Failed to get plugin directory permissions", err) + return nil + } + + permInfo := &pluginPermissionInfo{ + dirPath: pluginDir, + dirMode: dirInfo.Mode().String(), + } + + // Check if it's a symlink + if dirInfo.Mode()&os.ModeSymlink != 0 { + permInfo.isSymlink = true + + // Get target path and permissions + targetPath, err := os.Readlink(pluginDir) + if err == nil { + if !filepath.IsAbs(targetPath) { + targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath) + } + permInfo.targetPath = targetPath + + if targetInfo, err := os.Stat(targetPath); err == nil { + permInfo.targetMode = targetInfo.Mode().String() + } + } + } + + // Get manifest file permissions + manifestPath := filepath.Join(pluginDir, "manifest.json") + if manifestInfo, err := os.Stat(manifestPath); err == nil { + permInfo.manifestMode = manifestInfo.Mode().String() + } + + // Get WASM file permissions (look for .wasm files) + entries, err := os.ReadDir(pluginDir) + if err == nil { + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".wasm" { + wasmPath := filepath.Join(pluginDir, entry.Name()) + if wasmInfo, err := os.Stat(wasmPath); err == nil { + permInfo.wasmMode = wasmInfo.Mode().String() + break // Just show the first WASM file found + } + } + } + } + + return permInfo +} + +// Command implementations + +func pluginList(cmd *cobra.Command, args []string) { + discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION") + + for _, discovery := range discoveries { + displayPluginTableRow(w, discovery) + } + w.Flush() +} + +func pluginInfo(cmd *cobra.Command, args []string) { + path := args[0] + pluginsDir := conf.Server.Plugins.Folder + + var manifest *schema.PluginManifest + var fileInfo *pluginFileInfo + var permInfo *pluginPermissionInfo + + if filepath.Ext(path) == pluginPackageExtension { + // It's a package file + pkg, err := loadAndValidatePackage(path) + if err != nil { + log.Fatal("Failed to load plugin package", err) + } + manifest = pkg.Manifest + fileInfo = getFileInfo(path) + // No permission info for package files + } else { + // It's a plugin name + pluginDir, err := validatePluginDirectory(pluginsDir, path) + if err != nil { + log.Fatal("Plugin validation failed", err) + } + + manifest, err = plugins.LoadManifest(pluginDir) + if err != nil { + log.Fatal("Failed to load plugin manifest", err) + } + + // Get permission info for installed plugins + permInfo = getPermissionInfo(pluginDir) + } + + displayPluginDetails(manifest, fileInfo, permInfo) +} + +func pluginInstall(cmd *cobra.Command, args []string) { + ndpPath := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pkg, err := loadAndValidatePackage(ndpPath) + if err != nil { + log.Fatal("Package validation failed", err) + } + + // Create target directory based on plugin name + targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name) + + // Check if plugin already exists + if utils.FileExists(targetDir) { + log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir, + "use", "navidrome plugin update") + } + + if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil { + log.Fatal("Plugin installation failed", err) + } + + fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version) +} + +func pluginRemove(cmd *cobra.Command, args []string) { + pluginName := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pluginDir, err := validatePluginDirectory(pluginsDir, pluginName) + if err != nil { + log.Fatal("Plugin validation failed", err) + } + + _, isSymlink, err := resolvePluginPath(pluginDir) + if err != nil { + log.Fatal("Failed to resolve plugin path", err) + } + + if isSymlink { + // For symlinked plugins (dev mode), just remove the symlink + if err := os.Remove(pluginDir); err != nil { + log.Fatal("Failed to remove plugin symlink", "name", pluginName, err) + } + fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName) + } else { + // For regular plugins, remove the entire directory + if err := os.RemoveAll(pluginDir); err != nil { + log.Fatal("Failed to remove plugin directory", "name", pluginName, err) + } + fmt.Printf("Plugin '%s' removed successfully\n", pluginName) + } +} + +func pluginUpdate(cmd *cobra.Command, args []string) { + ndpPath := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pkg, err := loadAndValidatePackage(ndpPath) + if err != nil { + log.Fatal("Package validation failed", err) + } + + // Check if plugin exists + targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name) + if !utils.FileExists(targetDir) { + log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir, + "use", "navidrome plugin install") + } + + // Create a backup of the existing plugin + backupDir := targetDir + ".bak." + time.Now().Format("20060102150405") + if err := os.Rename(targetDir, backupDir); err != nil { + log.Fatal("Failed to backup existing plugin", err) + } + + // Extract the new package + if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil { + // Restore backup if extraction failed + os.RemoveAll(targetDir) + _ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path + log.Fatal("Plugin update failed", err) + } + + // Remove the backup + os.RemoveAll(backupDir) + + fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version) +} + +func pluginRefresh(cmd *cobra.Command, args []string) { + pluginName := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pluginDir, err := validatePluginDirectory(pluginsDir, pluginName) + if err != nil { + log.Fatal("Plugin validation failed", err) + } + + resolvedPath, isSymlink, err := resolvePluginPath(pluginDir) + if err != nil { + log.Fatal("Failed to resolve plugin path", err) + } + + if isSymlink { + log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath) + } + + fmt.Printf("Refreshing plugin '%s'...\n", pluginName) + + // Get the plugin manager and refresh + mgr := GetPluginManager(cmd.Context()) + log.Debug("Scanning plugins directory", "path", pluginsDir) + mgr.ScanPlugins() + + log.Info("Waiting for plugin compilation to complete", "name", pluginName) + + // Wait for compilation to complete + if err := mgr.EnsureCompiled(pluginName); err != nil { + log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err) + } + + log.Info("Plugin compilation completed successfully", "name", pluginName) + fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName) +} + +func pluginDev(cmd *cobra.Command, args []string) { + sourcePath, err := filepath.Abs(args[0]) + if err != nil { + log.Fatal("Invalid path", "path", args[0], err) + } + pluginsDir := conf.Server.Plugins.Folder + + // Validate source directory and manifest + if err := validateDevSource(sourcePath); err != nil { + log.Fatal("Source validation failed", err) + } + + // Load manifest to get plugin name + manifest, err := plugins.LoadManifest(sourcePath) + if err != nil { + log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err) + } + + pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath)) + targetPath := filepath.Join(pluginsDir, pluginName) + + // Handle existing target + if err := handleExistingTarget(targetPath, sourcePath); err != nil { + log.Fatal("Failed to handle existing target", err) + } + + // Create target directory if needed + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err) + } + + // Create the symlink + if err := os.Symlink(sourcePath, targetPath); err != nil { + log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err) + } + + fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath) + fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName) +} + +// Utility functions + +func validateDevSource(sourcePath string) error { + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err) + } + if !sourceInfo.IsDir() { + return fmt.Errorf("source path is not a directory: %s", sourcePath) + } + + manifestPath := filepath.Join(sourcePath, "manifest.json") + if !utils.FileExists(manifestPath) { + return fmt.Errorf("source folder missing manifest.json: %s", sourcePath) + } + + return nil +} + +func handleExistingTarget(targetPath, sourcePath string) error { + if !utils.FileExists(targetPath) { + return nil // Nothing to handle + } + + // Check if it's already a symlink to our source + existingLink, err := os.Readlink(targetPath) + if err == nil && existingLink == sourcePath { + fmt.Printf("Symlink already exists and points to the correct source\n") + return fmt.Errorf("symlink already exists") // This will cause early return in caller + } + + // Handle case where target exists but is not a symlink to our source + fmt.Printf("Target path '%s' already exists.\n", targetPath) + fmt.Print("Do you want to replace it? (y/N): ") + var response string + _, err = fmt.Scanln(&response) + if err != nil || strings.ToLower(response) != "y" { + if err != nil { + log.Debug("Error reading input, assuming 'no'", err) + } + return fmt.Errorf("operation canceled") + } + + // Remove existing target + if err := os.RemoveAll(targetPath); err != nil { + return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err) + } + + return nil +} + +func ensurePluginDirPermissions(dir string) { + if err := os.Chmod(dir, pluginDirPermissions); err != nil { + log.Error("Failed to set plugin directory permissions", "dir", dir, err) + } + + // Apply permissions to all files in the directory + entries, err := os.ReadDir(dir) + if err != nil { + log.Error("Failed to read plugin directory", "dir", dir, err) + return + } + + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + info, err := os.Stat(path) + if err != nil { + log.Error("Failed to stat file", "path", path, err) + continue + } + + mode := os.FileMode(pluginFilePermissions) // Files + if info.IsDir() { + mode = os.FileMode(pluginDirPermissions) // Directories + ensurePluginDirPermissions(path) // Recursive + } + + if err := os.Chmod(path, mode); err != nil { + log.Error("Failed to set file permissions", "path", path, err) + } + } +} + +func calculateSHA256(filePath string) string { + file, err := os.Open(filePath) + if err != nil { + log.Error("Failed to open file for hashing", err) + return "N/A" + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + log.Error("Failed to calculate hash", err) + return "N/A" + } + + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go new file mode 100644 index 000000000..3a4aefa88 --- /dev/null +++ b/cmd/plugin_test.go @@ -0,0 +1,193 @@ +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()) + }) + }) +}) diff --git a/cmd/root.go b/cmd/root.go index e1e92228f..df39f50a6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -82,8 +82,9 @@ func runNavidrome(ctx context.Context) { g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBOptimizer(ctx)) + g.Go(startPluginManager(ctx)) + g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { - g.Go(runInitialScan(ctx)) g.Go(startScanWatcher(ctx)) g.Go(schedulePeriodicScan(ctx)) } else { @@ -147,7 +148,7 @@ func schedulePeriodicScan(ctx context.Context) func() error { schedulerInstance := scheduler.GetInstance() log.Info("Scheduling periodic scan", "schedule", schedule) - err := schedulerInstance.Add(schedule, func() { + _, err := schedulerInstance.Add(schedule, func() { _, err := s.ScanAll(ctx, false) if err != nil { log.Error(ctx, "Error executing periodic scan", err) @@ -172,6 +173,7 @@ func pidHashChanged(ds model.DataStore) (bool, error) { return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil } +// runInitialScan runs an initial scan of the music library if needed. func runInitialScan(ctx context.Context) func() error { return func() error { ds := CreateDataStore() @@ -190,7 +192,7 @@ func runInitialScan(ctx context.Context) func() error { scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan if scanNeeded { - scanner := CreateScanner(ctx) + s := CreateScanner(ctx) switch { case fullScanRequired == "1": log.Warn(ctx, "Full scan required after migration") @@ -204,7 +206,7 @@ func runInitialScan(ctx context.Context) func() error { log.Info("Executing initial scan") } - _, err = scanner.ScanAll(ctx, fullScanRequired == "1") + _, err = s.ScanAll(ctx, fullScanRequired == "1") if err != nil { log.Error(ctx, "Scan failed", err) } else { @@ -243,7 +245,7 @@ func schedulePeriodicBackup(ctx context.Context) func() error { schedulerInstance := scheduler.GetInstance() log.Info("Scheduling periodic backup", "schedule", schedule) - err := schedulerInstance.Add(schedule, func() { + _, err := schedulerInstance.Add(schedule, func() { start := time.Now() path, err := db.Backup(ctx) elapsed := time.Since(start) @@ -271,7 +273,7 @@ func scheduleDBOptimizer(ctx context.Context) func() error { return func() error { log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) schedulerInstance := scheduler.GetInstance() - err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { + _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { if scanner.IsScanning() { log.Debug(ctx, "Skipping DB optimization because a scan is in progress") return @@ -325,6 +327,22 @@ func startPlaybackServer(ctx context.Context) func() error { } } +// startPluginManager starts the plugin manager, if configured. +func startPluginManager(ctx context.Context) func() error { + return func() error { + if !conf.Server.Plugins.Enabled { + log.Debug("Plugins are DISABLED") + return nil + } + log.Info(ctx, "Starting plugin manager") + // Get the manager instance and scan for plugins + manager := GetPluginManager(ctx) + manager.ScanPlugins() + + return nil + } +} + // TODO: Implement some struct tags to map flags to viper func init() { cobra.OnInitialize(func() { diff --git a/cmd/scan.go b/cmd/scan.go index 26eb7d7a2..d37ccd69f 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -6,8 +6,6 @@ import ( "os" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/persistence" @@ -70,7 +68,7 @@ func runScanner(ctx context.Context) { ds := persistence.New(sqlDB) pls := core.NewPlaylists(ds) - progress, err := scanner.CallScan(ctx, ds, artwork.NoopCacheWarmer(), pls, metrics.NewNoopInstance(), fullScan) + progress, err := scanner.CallScan(ctx, ds, pls, fullScan) if err != nil { log.Fatal(ctx, "Failed to scan", err) } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d57aadc71..59cf91e89 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" @@ -66,7 +67,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - agentsAgents := agents.GetAgents(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := core.GetTranscodingCache() @@ -77,11 +80,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) - metricsMetrics := metrics.NewPrometheusInstance(dataStore) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) - playTracker := scrobbler.GetPlayTracker(dataStore, broker) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) return router } @@ -90,7 +92,9 @@ func CreatePublicRouter() *public.Router { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - agentsAgents := agents.GetAgents(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := core.GetTranscodingCache() @@ -125,7 +129,7 @@ func CreateInsights() metrics.Insights { func CreatePrometheus() metrics.Metrics { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - metricsMetrics := metrics.NewPrometheusInstance(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) return metricsMetrics } @@ -134,13 +138,14 @@ func CreateScanner(ctx context.Context) scanner.Scanner { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - agentsAgents := agents.GetAgents(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, 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) - metricsMetrics := metrics.NewPrometheusInstance(dataStore) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) return scannerScanner } @@ -150,13 +155,14 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() - agentsAgents := agents.GetAgents(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, 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) - metricsMetrics := metrics.NewPrometheusInstance(dataStore) scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) watcher := scanner.NewWatcher(dataStore, scannerScanner) return watcher @@ -169,6 +175,20 @@ func GetPlaybackServer() playback.PlaybackServer { return playbackServer } +func getPluginManager() *plugins.Manager { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, 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.NewWatcher, metrics.NewPrometheusInstance, db.Db) +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.NewWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager))) + +func GetPluginManager(ctx context.Context) *plugins.Manager { + manager := getPluginManager() + manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) + return manager +} diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index c431945dc..9530e9bcf 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -7,14 +7,17 @@ import ( "github.com/google/wire" "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" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" @@ -36,8 +39,11 @@ var allProviders = wire.NewSet( events.GetBroker, scanner.New, scanner.NewWatcher, - metrics.NewPrometheusInstance, + plugins.GetManager, + metrics.GetPrometheusInstance, db.Db, + wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), ) func CreateDataStore() model.DataStore { @@ -111,3 +117,15 @@ func GetPlaybackServer() playback.PlaybackServer { allProviders, )) } + +func getPluginManager() *plugins.Manager { + panic(wire.Build( + allProviders, + )) +} + +func GetPluginManager(ctx context.Context) *plugins.Manager { + manager := getPluginManager() + manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) + return manager +} diff --git a/conf/configuration.go b/conf/configuration.go index eebf1c004..258e3727f 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -14,7 +14,7 @@ import ( "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/chain" + "github.com/navidrome/navidrome/utils/run" "github.com/robfig/cron/v3" "github.com/spf13/viper" ) @@ -66,6 +66,7 @@ type configOptions struct { CoverArtPriority string CoverJpegQuality int ArtistArtPriority string + LyricsPriority string EnableGravatar bool EnableFavourites bool EnableStarRating bool @@ -79,6 +80,7 @@ type configOptions struct { DefaultUIVolume int EnableReplayGain bool EnableCoverAnimation bool + EnableNowPlaying bool GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -86,25 +88,26 @@ type configOptions struct { PasswordEncryptionKey string ReverseProxyUserHeader string ReverseProxyWhitelist string - HTTPSecurityHeaders secureOptions - Prometheus prometheusOptions - Scanner scannerOptions - Jukebox jukeboxOptions - Backup backupOptions - PID pidOptions - Inspect inspectOptions - Subsonic subsonicOptions - LyricsPriority string - - Agents string - LastFM lastfmOptions - Spotify spotifyOptions - ListenBrainz listenBrainzOptions - Tags map[string]TagConf + Plugins pluginsOptions + PluginConfig map[string]map[string]string + HTTPSecurityHeaders secureOptions `json:",omitzero"` + Prometheus prometheusOptions `json:",omitzero"` + Scanner scannerOptions `json:",omitzero"` + Jukebox jukeboxOptions `json:",omitzero"` + Backup backupOptions `json:",omitzero"` + PID pidOptions `json:",omitzero"` + Inspect inspectOptions `json:",omitzero"` + Subsonic subsonicOptions `json:",omitzero"` + LastFM lastfmOptions `json:",omitzero"` + Spotify spotifyOptions `json:",omitzero"` + Deezer deezerOptions `json:",omitzero"` + ListenBrainz listenBrainzOptions `json:",omitzero"` + Tags map[string]TagConf `json:",omitempty"` + Agents string // DevFlags. These are used to enable/disable debugging and incomplete features + DevLogLevels map[string]string `json:",omitempty"` DevLogSourceLine bool - DevLogLevels map[string]string DevEnableProfiler bool DevAutoCreateAdminPassword string DevAutoLoginUsername string @@ -112,6 +115,8 @@ type configOptions struct { DevActivityPanelUpdateRate time.Duration DevSidebarPlaylists bool DevShowArtistPage bool + DevUIShowConfig bool + DevNewEventStream bool DevOffsetOptimize int DevArtworkMaxRequests int DevArtworkThrottleBacklogLimit int @@ -122,6 +127,8 @@ type configOptions struct { DevScannerThreads uint DevInsightsInitialDelay time.Duration DevEnablePlayerInsights bool + DevPluginCompilationTimeout time.Duration + DevExternalArtistFetchMultiplier float64 } type scannerOptions struct { @@ -134,6 +141,7 @@ type scannerOptions struct { GenreSeparators string // Deprecated: Use Tags.genre.Split instead GroupAlbumReleases bool // Deprecated: Use PID.Album instead FollowSymlinks bool // Whether to follow symlinks when scanning directories + PurgeMissing string // Values: "never", "always", "full" } type subsonicOptions struct { @@ -144,19 +152,20 @@ type subsonicOptions struct { } type TagConf struct { - Ignore bool `yaml:"ignore"` - Aliases []string `yaml:"aliases"` - Type string `yaml:"type"` - MaxLength int `yaml:"maxLength"` - Split []string `yaml:"split"` - Album bool `yaml:"album"` + Ignore bool `yaml:"ignore" json:",omitempty"` + Aliases []string `yaml:"aliases" json:",omitempty"` + Type string `yaml:"type" json:",omitempty"` + MaxLength int `yaml:"maxLength" json:",omitempty"` + Split []string `yaml:"split" json:",omitempty"` + Album bool `yaml:"album" json:",omitempty"` } type lastfmOptions struct { - Enabled bool - ApiKey string - Secret string - Language string + Enabled bool + ApiKey string + Secret string + Language string + ScrobbleFirstArtistOnly bool } type spotifyOptions struct { @@ -164,6 +173,10 @@ type spotifyOptions struct { Secret string } +type deezerOptions struct { + Enabled bool +} + type listenBrainzOptions struct { Enabled bool BaseURL string @@ -206,6 +219,12 @@ type inspectOptions struct { BacklogTimeout int } +type pluginsOptions struct { + Enabled bool + Folder string + CacheSize string +} + var ( Server = &configOptions{} hooks []func() @@ -245,6 +264,15 @@ func Load(noConfigDump bool) { os.Exit(1) } + if Server.Plugins.Folder == "" { + Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") + } + err = os.MkdirAll(Server.Plugins.Folder, 0700) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) + os.Exit(1) + } + Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) @@ -273,10 +301,11 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) - err = chain.RunSequentially( + err = run.Sequentially( validateScanSchedule, validateBackupSchedule, validatePlaylistsPath, + validatePurgeMissingOption, ) if err != nil { os.Exit(1) @@ -364,6 +393,7 @@ func disableExternalServices() { Server.EnableInsightsCollector = false Server.LastFM.Enabled = false Server.Spotify.ID = "" + Server.Deezer.Enabled = false Server.ListenBrainz.Enabled = false Server.Agents = "" if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL { @@ -382,6 +412,24 @@ func validatePlaylistsPath() error { return nil } +func validatePurgeMissingOption() error { + allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} + valid := false + for _, v := range allowedValues { + if v == Server.Scanner.PurgeMissing { + valid = true + break + } + } + if !valid { + err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) + log.Error(err.Error()) + Server.Scanner.PurgeMissing = consts.PurgeMissingNever + return err + } + return nil +} + func validateScanSchedule() error { if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { Server.Scanner.Schedule = "" @@ -421,7 +469,7 @@ func AddHook(hook func()) { hooks = append(hooks, hook) } -func init() { +func setViperDefaults() { viper.SetDefault("musicfolder", filepath.Join(".", "music")) viper.SetDefault("cachefolder", "") viper.SetDefault("datafolder", ".") @@ -457,11 +505,11 @@ func init() { viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") - viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s") - + viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverjpegquality", 75) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") + viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) @@ -471,6 +519,7 @@ func init() { viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) + viper.SetDefault("enablenowplaying", true) viper.SetDefault("enablesharing", false) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) @@ -481,19 +530,15 @@ func init() { viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("passwordencryptionkey", "") - viper.SetDefault("reverseproxyuserheader", "Remote-User") viper.SetDefault("reverseproxywhitelist", "") - viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.password", "") - viper.SetDefault("jukebox.enabled", false) viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{}) viper.SetDefault("jukebox.default", "") viper.SetDefault("jukebox.adminonly", true) - viper.SetDefault("scanner.enabled", true) viper.SetDefault("scanner.schedule", "0") viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor) @@ -503,37 +548,35 @@ func init() { viper.SetDefault("scanner.genreseparators", "") viper.SetDefault("scanner.groupalbumreleases", false) viper.SetDefault("scanner.followsymlinks", true) - + viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) viper.SetDefault("subsonic.appendsubtitle", true) viper.SetDefault("subsonic.artistparticipations", false) viper.SetDefault("subsonic.defaultreportrealpath", false) viper.SetDefault("subsonic.legacyclients", "DSub") - - viper.SetDefault("agents", "lastfm,spotify") + viper.SetDefault("agents", "lastfm,spotify,deezer") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", "en") viper.SetDefault("lastfm.apikey", "") viper.SetDefault("lastfm.secret", "") + viper.SetDefault("lastfm.scrobblefirstartistonly", false) viper.SetDefault("spotify.id", "") viper.SetDefault("spotify.secret", "") + viper.SetDefault("deezer.enabled", true) viper.SetDefault("listenbrainz.enabled", true) viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") - viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY") - viper.SetDefault("backup.path", "") viper.SetDefault("backup.schedule", "") viper.SetDefault("backup.count", 0) - viper.SetDefault("pid.track", consts.DefaultTrackPID) viper.SetDefault("pid.album", consts.DefaultAlbumPID) - viper.SetDefault("inspect.enabled", true) viper.SetDefault("inspect.maxrequests", 1) viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout) - - viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") + viper.SetDefault("plugins.folder", "") + viper.SetDefault("plugins.enabled", false) + viper.SetDefault("plugins.cachesize", "100MB") // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) @@ -544,6 +587,8 @@ func init() { viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond) viper.SetDefault("devsidebarplaylists", true) viper.SetDefault("devshowartistpage", true) + viper.SetDefault("devuishowconfig", true) + viper.SetDefault("devneweventstream", true) viper.SetDefault("devoffsetoptimize", 50000) viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) @@ -554,6 +599,12 @@ func init() { viper.SetDefault("devscannerthreads", 5) viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) viper.SetDefault("devenableplayerinsights", true) + viper.SetDefault("devplugincompilationtimeout", time.Minute) + viper.SetDefault("devexternalartistfetchmultiplier", 1.5) +} + +func init() { + setViperDefaults() } func InitConfig(cfgFile string) { diff --git a/conf/configuration_test.go b/conf/configuration_test.go index f57764709..5b54e4975 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - . "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/viper" @@ -20,9 +20,10 @@ var _ = Describe("Configuration", func() { BeforeEach(func() { // Reset viper configuration viper.Reset() + conf.SetViperDefaults() viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("loglevel", "error") - ResetConf() + conf.ResetConf() }) DescribeTable("should load configuration from", @@ -30,17 +31,17 @@ var _ = Describe("Configuration", func() { filename := filepath.Join("testdata", "cfg."+format) // Initialize config with the test file - InitConfig(filename) + conf.InitConfig(filename) // Load the configuration (with noConfigDump=true) - Load(true) + conf.Load(true) // Execute the format-specific assertions - Expect(Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format))) - Expect(Server.UIWelcomeMessage).To(Equal("Welcome " + format)) - Expect(Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"})) + Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format))) + Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format)) + Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"})) // The config file used should be the one we created - Expect(Server.ConfigFile).To(Equal(filename)) + Expect(conf.Server.ConfigFile).To(Equal(filename)) }, Entry("TOML format", "toml"), Entry("YAML format", "yaml"), diff --git a/conf/export_test.go b/conf/export_test.go index 0bd7819eb..1b6daf036 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -3,3 +3,5 @@ package conf func ResetConf() { Server = &configOptions{} } + +var SetViperDefaults = setViperDefaults diff --git a/consts/consts.go b/consts/consts.go index 2dbb46d07..fbb2c9429 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -115,6 +115,12 @@ const ( InsightsInitialDelay = 30 * time.Minute ) +const ( + PurgeMissingNever = "never" + PurgeMissingAlways = "always" + PurgeMissingFull = "full" +) + var ( DefaultDownsamplingFormat = "opus" DefaultTranscodings = []struct { diff --git a/core/agents/agents.go b/core/agents/agents.go index 50a1e04ad..efa9f383d 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -2,7 +2,9 @@ package agents import ( "context" + "slices" "strings" + "sync" "time" "github.com/navidrome/navidrome/conf" @@ -13,43 +15,156 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) -type Agents struct { - ds model.DataStore - agents []Interface +// PluginLoader defines an interface for loading plugins +type PluginLoader interface { + // PluginNames returns the names of all plugins that implement a particular service + PluginNames(serviceName string) []string + // LoadMediaAgent loads and returns a media agent plugin + LoadMediaAgent(name string) (Interface, bool) } -func GetAgents(ds model.DataStore) *Agents { +type cachedAgent struct { + agent Interface + expiration time.Time +} + +// Encapsulates agent caching logic +// agentCache is a simple TTL cache for agents +// Not exported, only used by Agents + +type agentCache struct { + mu sync.Mutex + items map[string]cachedAgent + ttl time.Duration +} + +// TTL for cached agents +const agentCacheTTL = 5 * time.Minute + +func newAgentCache(ttl time.Duration) *agentCache { + return &agentCache{ + items: make(map[string]cachedAgent), + ttl: ttl, + } +} + +func (c *agentCache) Get(name string) Interface { + c.mu.Lock() + defer c.mu.Unlock() + cached, ok := c.items[name] + if ok && cached.expiration.After(time.Now()) { + return cached.agent + } + return nil +} + +func (c *agentCache) Set(name string, agent Interface) { + c.mu.Lock() + defer c.mu.Unlock() + c.items[name] = cachedAgent{ + agent: agent, + expiration: time.Now().Add(c.ttl), + } +} + +type Agents struct { + ds model.DataStore + pluginLoader PluginLoader + cache *agentCache +} + +// GetAgents returns the singleton instance of Agents +func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { return singleton.GetInstance(func() *Agents { - return createAgents(ds) + return createAgents(ds, pluginLoader) }) } -func createAgents(ds model.DataStore) *Agents { - var order []string - if conf.Server.Agents != "" { - order = strings.Split(conf.Server.Agents, ",") +// createAgents creates a new Agents instance. Used in tests +func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { + return &Agents{ + ds: ds, + pluginLoader: pluginLoader, + cache: newAgentCache(agentCacheTTL), } - order = append(order, LocalAgentName) - var res []Interface - var enabled []string - for _, name := range order { - init, ok := Map[name] - if !ok { - log.Error("Invalid agent. Check `Agents` configuration", "name", name, "conf", conf.Server.Agents) - continue - } +} - agent := init(ds) - if agent == nil { - log.Debug("Agent not available. Missing configuration?", "name", name) - continue - } - enabled = append(enabled, name) - res = append(res, init(ds)) +// getEnabledAgentNames returns the current list of enabled agent names, including: +// 1. Built-in agents and plugins from config (in the specified order) +// 2. Always include LocalAgentName +// 3. If config is empty, include ONLY LocalAgentName +func (a *Agents) getEnabledAgentNames() []string { + // If no agents configured, ONLY use the local agent + if conf.Server.Agents == "" { + return []string{LocalAgentName} } - log.Debug("List of agents enabled", "names", enabled) - return &Agents{ds: ds, agents: res} + // Get all available plugin names + var availablePlugins []string + if a.pluginLoader != nil { + availablePlugins = a.pluginLoader.PluginNames("MetadataAgent") + } + + configuredAgents := strings.Split(conf.Server.Agents, ",") + + // Always add LocalAgentName if not already included + hasLocalAgent := false + for _, name := range configuredAgents { + if name == LocalAgentName { + hasLocalAgent = true + break + } + } + if !hasLocalAgent { + configuredAgents = append(configuredAgents, LocalAgentName) + } + + // Filter to only include valid agents (built-in or plugins) + var validNames []string + for _, name := range configuredAgents { + // Check if it's a built-in agent + isBuiltIn := Map[name] != nil + + // Check if it's a plugin + isPlugin := slices.Contains(availablePlugins, name) + + if isBuiltIn || isPlugin { + validNames = append(validNames, name) + } else { + log.Warn("Unknown agent ignored", "name", name) + } + } + return validNames +} + +func (a *Agents) getAgent(name string) Interface { + // Check cache first + agent := a.cache.Get(name) + if agent != nil { + return agent + } + + // Try to get built-in agent + constructor, ok := Map[name] + if ok { + agent := constructor(a.ds) + if agent != nil { + a.cache.Set(name, agent) + return agent + } + log.Debug("Built-in agent not available. Missing configuration?", "name", name) + } + + // Try to load WASM plugin agent (if plugin loader is available) + if a.pluginLoader != nil { + agent, ok := a.pluginLoader.LoadMediaAgent(name) + if ok && agent != nil { + a.cache.Set(name, agent) + return agent + } + } + + return nil } func (a *Agents) AgentName() string { @@ -64,15 +179,19 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str return "", nil } start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(ArtistMBIDRetriever) + retriever, ok := ag.(ArtistMBIDRetriever) if !ok { continue } - mbid, err := agent.GetArtistMBID(ctx, id, name) + mbid, err := retriever.GetArtistMBID(ctx, id, name) if mbid != "" && err == nil { log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start)) return mbid, nil @@ -89,15 +208,19 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin return "", nil } start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(ArtistURLRetriever) + retriever, ok := ag.(ArtistURLRetriever) if !ok { continue } - url, err := agent.GetArtistURL(ctx, id, name, mbid) + url, err := retriever.GetArtistURL(ctx, id, name, mbid) if url != "" && err == nil { log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start)) return url, nil @@ -114,15 +237,19 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return "", nil } start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(ArtistBiographyRetriever) + retriever, ok := ag.(ArtistBiographyRetriever) if !ok { continue } - bio, err := agent.GetArtistBiography(ctx, id, name, mbid) + bio, err := retriever.GetArtistBiography(ctx, id, name, mbid) if err == nil { log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start)) return bio, nil @@ -131,6 +258,8 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return "", ErrNotFound } +// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled +// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items. func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) { switch id { case consts.UnknownArtistID: @@ -138,16 +267,23 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l case consts.VariousArtistsID: return nil, nil } + + overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) + start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(ArtistSimilarRetriever) + retriever, ok := ag.(ArtistSimilarRetriever) if !ok { continue } - similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit) + similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) if len(similar) > 0 && err == nil { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) @@ -168,15 +304,19 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return nil, nil } start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(ArtistImageRetriever) + retriever, ok := ag.(ArtistImageRetriever) if !ok { continue } - images, err := agent.GetArtistImages(ctx, id, name, mbid) + images, err := retriever.GetArtistImages(ctx, id, name, mbid) if len(images) > 0 && err == nil { log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start)) return images, nil @@ -185,6 +325,8 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return nil, ErrNotFound } +// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled +// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items. func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { switch id { case consts.UnknownArtistID: @@ -192,16 +334,23 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str case consts.VariousArtistsID: return nil, nil } + + overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier) + start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(ArtistTopSongsRetriever) + retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { continue } - songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count) + songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) if len(songs) > 0 && err == nil { log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) return songs, nil @@ -215,15 +364,19 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return nil, ErrNotFound } start := time.Now() - for _, ag := range a.agents { + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } if utils.IsCtxDone(ctx) { break } - agent, ok := ag.(AlbumInfoRetriever) + retriever, ok := ag.(AlbumInfoRetriever) if !ok { continue } - album, err := agent.GetAlbumInfo(ctx, name, artist, mbid) + album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid) if err == nil { log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, "elapsed", time.Since(start)) @@ -233,6 +386,33 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return nil, ErrNotFound } +func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) { + if name == consts.UnknownAlbum { + return nil, ErrNotFound + } + start := time.Now() + for _, agentName := range a.getEnabledAgentNames() { + ag := a.getAgent(agentName) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(AlbumImageRetriever) + if !ok { + continue + } + images, err := retriever.GetAlbumImages(ctx, name, artist, mbid) + 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)) + return images, nil + } + } + return nil, ErrNotFound +} + var _ Interface = (*Agents)(nil) var _ ArtistMBIDRetriever = (*Agents)(nil) var _ ArtistURLRetriever = (*Agents)(nil) @@ -241,3 +421,4 @@ var _ ArtistSimilarRetriever = (*Agents)(nil) var _ ArtistImageRetriever = (*Agents)(nil) var _ ArtistTopSongsRetriever = (*Agents)(nil) var _ AlbumInfoRetriever = (*Agents)(nil) +var _ AlbumImageRetriever = (*Agents)(nil) diff --git a/core/agents/agents_plugin_test.go b/core/agents/agents_plugin_test.go new file mode 100644 index 000000000..575fcbebe --- /dev/null +++ b/core/agents/agents_plugin_test.go @@ -0,0 +1,221 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// MockPluginLoader implements PluginLoader for testing +type MockPluginLoader struct { + pluginNames []string + loadedAgents map[string]*MockAgent + pluginCallCount map[string]int +} + +func NewMockPluginLoader() *MockPluginLoader { + return &MockPluginLoader{ + pluginNames: []string{}, + loadedAgents: make(map[string]*MockAgent), + pluginCallCount: make(map[string]int), + } +} + +func (m *MockPluginLoader) PluginNames(serviceName string) []string { + return m.pluginNames +} + +func (m *MockPluginLoader) LoadMediaAgent(name string) (Interface, bool) { + m.pluginCallCount[name]++ + agent, exists := m.loadedAgents[name] + return agent, exists +} + +// MockAgent is a mock agent implementation for testing +type MockAgent struct { + name string + mbid string +} + +func (m *MockAgent) AgentName() string { + return m.name +} + +func (m *MockAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { + return m.mbid, nil +} + +var _ Interface = (*MockAgent)(nil) +var _ ArtistMBIDRetriever = (*MockAgent)(nil) + +var _ PluginLoader = (*MockPluginLoader)(nil) + +var _ = Describe("Agents with Plugin Loading", func() { + var mockLoader *MockPluginLoader + var agents *Agents + + BeforeEach(func() { + mockLoader = NewMockPluginLoader() + + // Create the agents instance with our mock loader + agents = createAgents(nil, mockLoader) + }) + + Context("Dynamic agent discovery", func() { + It("should include ONLY local agent when no config is specified", func() { + // Ensure no specific agents are configured + conf.Server.Agents = "" + + // Add some plugin agents that should be ignored + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin") + + // Should only include the local agent + agentNames := agents.getEnabledAgentNames() + Expect(agentNames).To(HaveExactElements(LocalAgentName)) + }) + + It("should NOT include plugin agents when no config is specified", func() { + // Ensure no specific agents are configured + conf.Server.Agents = "" + + // Add a plugin agent + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") + + // Should only include the local agent + agentNames := agents.getEnabledAgentNames() + Expect(agentNames).To(HaveExactElements(LocalAgentName)) + Expect(agentNames).NotTo(ContainElement("plugin_agent")) + }) + + It("should include plugin agents in the enabled agents list ONLY when explicitly configured", func() { + // Add a plugin agent + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") + + // With no config, should not include plugin + conf.Server.Agents = "" + agentNames := agents.getEnabledAgentNames() + Expect(agentNames).To(HaveExactElements(LocalAgentName)) + Expect(agentNames).NotTo(ContainElement("plugin_agent")) + + // When explicitly configured, should include plugin + conf.Server.Agents = "plugin_agent" + agentNames = agents.getEnabledAgentNames() + Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_agent")) + }) + + It("should only include configured plugin agents when config is specified", func() { + // Add two plugin agents + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two") + + // Configure only one of them + conf.Server.Agents = "plugin_one" + + // Verify only the configured one is included + agentNames := agents.getEnabledAgentNames() + Expect(agentNames).To(ContainElement("plugin_one")) + Expect(agentNames).NotTo(ContainElement("plugin_two")) + }) + + It("should load plugin agents on demand", func() { + ctx := context.Background() + + // Configure to use our plugin + conf.Server.Agents = "plugin_agent" + + // Add a plugin agent + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") + mockLoader.loadedAgents["plugin_agent"] = &MockAgent{ + name: "plugin_agent", + mbid: "plugin-mbid", + } + + // Try to get data from it + mbid, err := agents.GetArtistMBID(ctx, "123", "Artist") + + Expect(err).ToNot(HaveOccurred()) + Expect(mbid).To(Equal("plugin-mbid")) + Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1)) + }) + + It("should cache plugin agents", func() { + ctx := context.Background() + + // Configure to use our plugin + conf.Server.Agents = "plugin_agent" + + // Add a plugin agent + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") + mockLoader.loadedAgents["plugin_agent"] = &MockAgent{ + name: "plugin_agent", + mbid: "plugin-mbid", + } + + // Call multiple times + _, err := agents.GetArtistMBID(ctx, "123", "Artist") + Expect(err).ToNot(HaveOccurred()) + _, err = agents.GetArtistMBID(ctx, "123", "Artist") + Expect(err).ToNot(HaveOccurred()) + _, err = agents.GetArtistMBID(ctx, "123", "Artist") + Expect(err).ToNot(HaveOccurred()) + + // Should only load once + Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1)) + }) + + It("should try both built-in and plugin agents", func() { + // Create a mock built-in agent + Register("built_in", func(ds model.DataStore) Interface { + return &MockAgent{ + name: "built_in", + mbid: "built-in-mbid", + } + }) + defer func() { + delete(Map, "built_in") + }() + + // Configure to use both built-in and plugin + conf.Server.Agents = "built_in,plugin_agent" + + // Add a plugin agent + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") + mockLoader.loadedAgents["plugin_agent"] = &MockAgent{ + name: "plugin_agent", + mbid: "plugin-mbid", + } + + // Verify that both are in the enabled list + agentNames := agents.getEnabledAgentNames() + Expect(agentNames).To(ContainElements("built_in", "plugin_agent")) + }) + + It("should respect the order specified in configuration", func() { + // Create mock built-in agents + Register("agent_a", func(ds model.DataStore) Interface { + return &MockAgent{name: "agent_a"} + }) + Register("agent_b", func(ds model.DataStore) Interface { + return &MockAgent{name: "agent_b"} + }) + defer func() { + delete(Map, "agent_a") + delete(Map, "agent_b") + }() + + // Add plugin agents + mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y") + + // Configure specific order - plugin first, then built-ins + conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a" + + // Get the agent names + agentNames := agents.getEnabledAgentNames() + + // Verify the order matches configuration, with LocalAgentName at the end + Expect(agentNames).To(HaveExactElements("plugin_y", "agent_b", "plugin_x", "agent_a", LocalAgentName)) + }) + }) +}) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index ea12fb746..0732d43ef 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -4,10 +4,10 @@ import ( "context" "errors" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/conf" . "github.com/onsi/ginkgo/v2" @@ -20,6 +20,7 @@ var _ = Describe("Agents", func() { var ds model.DataStore var mfRepo *tests.MockMediaFileRepo BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) ctx, cancel = context.WithCancel(context.Background()) mfRepo = tests.CreateMockMediaFileRepo() ds = &tests.MockDataStore{MockedMediaFile: mfRepo} @@ -29,7 +30,7 @@ var _ = Describe("Agents", func() { var ag *Agents BeforeEach(func() { conf.Server.Agents = "" - ag = createAgents(ds) + ag = createAgents(ds, nil) }) It("calls the placeholder GetArtistImages", func() { @@ -49,12 +50,18 @@ var _ = Describe("Agents", func() { Register("disabled", func(model.DataStore) Interface { return nil }) Register("empty", func(model.DataStore) Interface { return &emptyAgent{} }) conf.Server.Agents = "empty,fake,disabled" - ag = createAgents(ds) + ag = createAgents(ds, nil) Expect(ag.AgentName()).To(Equal("agents")) }) It("does not register disabled agents", func() { - ags := slice.Map(ag.agents, func(a Interface) string { return a.AgentName() }) + var ags []string + for _, name := range ag.getEnabledAgentNames() { + agent := ag.getAgent(name) + if agent != nil { + ags = append(ags, agent.AgentName()) + } + } // local agent is always appended to the end of the agents list Expect(ags).To(HaveExactElements("empty", "fake", "local")) Expect(ags).ToNot(ContainElement("disabled")) @@ -173,6 +180,42 @@ var _ = Describe("Agents", func() { Expect(err).To(MatchError(ErrNotFound)) Expect(mock.Args).To(BeEmpty()) }) + + Context("with multiple image agents", func() { + var first *testImageAgent + var second *testImageAgent + + BeforeEach(func() { + first = &testImageAgent{Name: "imgFail", Err: errors.New("fail")} + second = &testImageAgent{Name: "imgOk", Images: []ExternalImage{{URL: "ok", Size: 1}}} + Register("imgFail", func(model.DataStore) Interface { return first }) + Register("imgOk", func(model.DataStore) Interface { return second }) + }) + + It("falls back to the next agent on error", func() { + conf.Server.Agents = "imgFail,imgOk" + ag = createAgents(ds, nil) + + images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}})) + Expect(first.Args).To(HaveExactElements("id", "artist", "mbid")) + Expect(second.Args).To(HaveExactElements("id", "artist", "mbid")) + }) + + It("falls back if the first agent returns no images", func() { + first.Err = nil + first.Images = []ExternalImage{} + conf.Server.Agents = "imgFail,imgOk" + ag = createAgents(ds, nil) + + images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}})) + Expect(first.Args).To(HaveExactElements("id", "artist", "mbid")) + Expect(second.Args).To(HaveExactElements("id", "artist", "mbid")) + }) + }) }) Describe("GetSimilarArtists", func() { @@ -199,6 +242,7 @@ var _ = Describe("Agents", func() { Describe("GetArtistTopSongs", func() { It("returns on first match", func() { + conf.Server.DevExternalArtistFetchMultiplier = 1 Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{ Name: "A Song", MBID: "mbid444", @@ -206,6 +250,7 @@ var _ = Describe("Agents", func() { Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2)) }) It("skips the agent if it returns an error", func() { + conf.Server.DevExternalArtistFetchMultiplier = 1 mock.Err = errors.New("error") _, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2) Expect(err).To(MatchError(ErrNotFound)) @@ -217,6 +262,14 @@ var _ = Describe("Agents", func() { Expect(err).To(MatchError(ErrNotFound)) Expect(mock.Args).To(BeEmpty()) }) + It("fetches with multiplier", func() { + conf.Server.DevExternalArtistFetchMultiplier = 2 + Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{ + Name: "A Song", + MBID: "mbid444", + }})) + Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 4)) + }) }) Describe("GetAlbumInfo", func() { @@ -226,18 +279,6 @@ var _ = Describe("Agents", func() { MBID: "mbid444", Description: "A Description", URL: "External URL", - Images: []ExternalImage{ - { - Size: 174, - URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png", - }, { - Size: 64, - URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png", - }, { - Size: 34, - URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png", - }, - }, })) Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid")) }) @@ -333,18 +374,6 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) MBID: "mbid444", Description: "A Description", URL: "External URL", - Images: []ExternalImage{ - { - Size: 174, - URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png", - }, { - Size: 64, - URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png", - }, { - Size: 34, - URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png", - }, - }, }, nil } @@ -355,3 +384,17 @@ type emptyAgent struct { func (e *emptyAgent) AgentName() string { return "empty" } + +type testImageAgent struct { + Name string + Images []ExternalImage + Err error + Args []interface{} +} + +func (t *testImageAgent) AgentName() string { return t.Name } + +func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { + t.Args = []interface{}{id, name, mbid} + return t.Images, t.Err +} diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go new file mode 100644 index 000000000..e75526d80 --- /dev/null +++ b/core/agents/deezer/client.go @@ -0,0 +1,83 @@ +package deezer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/navidrome/navidrome/log" +) + +const apiBaseURL = "https://api.deezer.com" + +var ( + ErrNotFound = errors.New("deezer: not found") +) + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type client struct { + httpDoer httpDoer +} + +func newClient(hc httpDoer) *client { + return &client{hc} +} + +func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { + params := url.Values{} + params.Add("q", name) + params.Add("limit", strconv.Itoa(limit)) + req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil) + if err != nil { + return nil, err + } + req.URL.RawQuery = params.Encode() + + var results SearchArtistResults + err = c.makeRequest(req, &results) + if err != nil { + return nil, err + } + + if len(results.Data) == 0 { + return nil, ErrNotFound + } + return results.Data, nil +} + +func (c *client) makeRequest(req *http.Request, response interface{}) error { + log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL) + resp, err := c.httpDoer.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return c.parseError(data) + } + + return json.Unmarshal(data, response) +} + +func (c *client) parseError(data []byte) error { + var deezerError Error + err := json.Unmarshal(data, &deezerError) + if err != nil { + return err + } + return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) +} diff --git a/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go new file mode 100644 index 000000000..5e47460d4 --- /dev/null +++ b/core/agents/deezer/client_test.go @@ -0,0 +1,68 @@ +package deezer + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("client", func() { + var httpClient *fakeHttpClient + var client *client + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + client = newClient(httpClient) + }) + + Describe("ArtistImages", func() { + It("returns artist images from a successful request", func() { + f, err := os.Open("tests/fixtures/deezer.search.artist.json") + Expect(err).To(BeNil()) + httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200}) + + artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20) + Expect(err).To(BeNil()) + Expect(artists).To(HaveLen(17)) + Expect(artists[0].Name).To(Equal("Michael Jackson")) + Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg")) + }) + + It("fails if artist was not found", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)), + }) + + _, err := client.searchArtists(context.TODO(), "Michael Jackson", 20) + Expect(err).To(MatchError(ErrNotFound)) + }) + }) +}) + +type fakeHttpClient struct { + responses map[string]*http.Response + lastRequest *http.Request +} + +func (c *fakeHttpClient) mock(url string, response http.Response) { + if c.responses == nil { + c.responses = make(map[string]*http.Response) + } + c.responses[url] = &response +} + +func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { + c.lastRequest = req + u := req.URL + u.RawQuery = "" + if resp, ok := c.responses[u.String()]; ok { + return resp, nil + } + panic("URL not mocked: " + u.String()) +} diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go new file mode 100644 index 000000000..8cabfbcfb --- /dev/null +++ b/core/agents/deezer/deezer.go @@ -0,0 +1,97 @@ +package deezer + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" +) + +const deezerAgentName = "deezer" +const deezerApiPictureXlSize = 1000 +const deezerApiPictureBigSize = 500 +const deezerApiPictureMediumSize = 250 +const deezerApiPictureSmallSize = 56 +const deezerArtistSearchLimit = 50 + +type deezerAgent struct { + dataStore model.DataStore + client *client +} + +func deezerConstructor(dataStore model.DataStore) agents.Interface { + agent := &deezerAgent{dataStore: dataStore} + httpClient := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) + agent.client = newClient(cachedHttpClient) + return agent +} + +func (s *deezerAgent) AgentName() string { + return deezerAgentName +} + +func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) { + artist, err := s.searchArtist(ctx, name) + if err != nil { + if errors.Is(err, agents.ErrNotFound) { + log.Warn(ctx, "Artist not found in deezer", "artist", name) + } else { + log.Error(ctx, "Error calling deezer", "artist", name, err) + } + return nil, err + } + + var res []agents.ExternalImage + possibleImages := []struct { + URL string + Size int + }{ + {artist.PictureXl, deezerApiPictureXlSize}, + {artist.PictureBig, deezerApiPictureBigSize}, + {artist.PictureMedium, deezerApiPictureMediumSize}, + {artist.PictureSmall, deezerApiPictureSmallSize}, + } + for _, imgData := range possibleImages { + if imgData.URL != "" { + res = append(res, agents.ExternalImage{ + URL: imgData.URL, + Size: imgData.Size, + }) + } + } + return res, nil +} + +func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { + artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) + if errors.Is(err, ErrNotFound) || len(artists) == 0 { + return nil, agents.ErrNotFound + } + if err != nil { + return nil, err + } + + // If the first one has the same name, that's the one + if !strings.EqualFold(artists[0].Name, name) { + return nil, agents.ErrNotFound + } + return &artists[0], err +} + +func init() { + conf.AddHook(func() { + if conf.Server.Deezer.Enabled { + agents.Register(deezerAgentName, deezerConstructor) + } + }) +} diff --git a/core/agents/deezer/deezer_suite_test.go b/core/agents/deezer/deezer_suite_test.go new file mode 100644 index 000000000..a42282da7 --- /dev/null +++ b/core/agents/deezer/deezer_suite_test.go @@ -0,0 +1,17 @@ +package deezer + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDeezer(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Deezer Test Suite") +} diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go new file mode 100644 index 000000000..112fe28ec --- /dev/null +++ b/core/agents/deezer/responses.go @@ -0,0 +1,31 @@ +package deezer + +type SearchArtistResults struct { + Data []Artist `json:"data"` + Total int `json:"total"` + Next string `json:"next"` +} + +type Artist struct { + ID int `json:"id"` + Name string `json:"name"` + Link string `json:"link"` + Picture string `json:"picture"` + PictureSmall string `json:"picture_small"` + PictureMedium string `json:"picture_medium"` + PictureBig string `json:"picture_big"` + PictureXl string `json:"picture_xl"` + NbAlbum int `json:"nb_album"` + NbFan int `json:"nb_fan"` + Radio bool `json:"radio"` + Tracklist string `json:"tracklist"` + Type string `json:"type"` +} + +type Error struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` +} diff --git a/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go new file mode 100644 index 000000000..95a7f43f4 --- /dev/null +++ b/core/agents/deezer/responses_test.go @@ -0,0 +1,38 @@ +package deezer + +import ( + "encoding/json" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Responses", func() { + Describe("Search type=artist", func() { + It("parses the artist search result correctly ", func() { + var resp SearchArtistResults + body, err := os.ReadFile("tests/fixtures/deezer.search.artist.json") + Expect(err).To(BeNil()) + err = json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Data).To(HaveLen(17)) + michael := resp.Data[0] + Expect(michael.Name).To(Equal("Michael Jackson")) + Expect(michael.PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg")) + }) + }) + + Describe("Error", func() { + It("parses the error response correctly", func() { + var errorResp Error + body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) + err := json.Unmarshal(body, &errorResp) + Expect(err).To(BeNil()) + + Expect(errorResp.Error.Code).To(Equal(501)) + Expect(errorResp.Error.Message).To(Equal("Missing parameters: q")) + }) + }) +}) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 00f75627d..e60c61909 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -13,12 +13,12 @@ type Interface interface { AgentName() string } +// AlbumInfo contains album metadata (no images) type AlbumInfo struct { Name string MBID string Description string URL string - Images []ExternalImage } type Artist struct { @@ -40,11 +40,16 @@ var ( ErrNotFound = errors.New("not found") ) -// TODO Break up this interface in more specific methods, like artists +// AlbumInfoRetriever provides album info (no images) type AlbumInfoRetriever interface { GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) } +// AlbumImageRetriever provides album images +type AlbumImageRetriever interface { + GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) +} + type ArtistMBIDRetriever interface { GetArtistMBID(ctx context.Context, id string, name string) (string, error) } diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go index 3f5f44d20..d01b496ec 100644 --- a/core/agents/lastfm/agent.go +++ b/core/agents/lastfm/agent.go @@ -72,16 +72,23 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin return nil, err } - response := agents.AlbumInfo{ + return &agents.AlbumInfo{ Name: a.Name, MBID: a.MBID, Description: a.Description.Summary, URL: a.URL, - Images: make([]agents.ExternalImage, 0), + }, nil +} + +func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + a, err := l.callAlbumGetInfo(ctx, name, artist, mbid) + if err != nil { + return nil, err } // Last.fm can return duplicate sizes. seenSizes := map[int]bool{} + images := make([]agents.ExternalImage, 0) // This assumes that Last.fm returns images with size small, medium, and large. // This is true as of December 29, 2022 @@ -92,23 +99,20 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size) continue } - numericSize, err := strconv.Atoi(size[0][2:]) if err != nil { log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err) return nil, err - } else { - if _, exists := seenSizes[numericSize]; !exists { - response.Images = append(response.Images, agents.ExternalImage{ - Size: numericSize, - URL: img.URL, - }) - seenSizes[numericSize] = true - } + } + if _, exists := seenSizes[numericSize]; !exists { + images = append(images, agents.ExternalImage{ + Size: numericSize, + URL: img.URL, + }) + seenSizes[numericSize] = true } } - - return &response, nil + return images, nil } func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { @@ -279,14 +283,21 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str return t.Track, nil } -func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { +func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string { + if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 { + return track.Participants[model.RoleArtist][0].Name + } + return track.Artist +} + +func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { sk, err := l.sessionKeys.Get(ctx, userId) if err != nil || sk == "" { return scrobbler.ErrNotAuthorized } err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{ - artist: track.Artist, + artist: l.getArtistForScrobble(track), track: track.Title, album: track.Album, trackNumber: track.TrackNumber, @@ -312,7 +323,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S return nil } err = l.client.scrobble(ctx, sk, ScrobbleInfo{ - artist: s.Artist, + artist: l.getArtistForScrobble(&s.MediaFile), track: s.Title, album: s.Album, trackNumber: s.TrackNumber, diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go index de4fac6d6..4476d592f 100644 --- a/core/agents/lastfm/agent_test.go +++ b/core/agents/lastfm/agent_test.go @@ -196,6 +196,12 @@ var _ = Describe("lastfmAgent", func() { TrackNumber: 1, Duration: 180, MbzRecordingID: "mbz-123", + Participants: map[model.Role]model.ParticipantList{ + model.RoleArtist: []model.Participant{ + {Artist: model.Artist{ID: "ar-1", Name: "First Artist"}}, + {Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}}, + }, + }, } }) @@ -203,7 +209,7 @@ var _ = Describe("lastfmAgent", func() { It("calls Last.fm with correct params", func() { httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} - err := agent.NowPlaying(ctx, "user-1", track) + err := agent.NowPlaying(ctx, "user-1", track, 0) Expect(err).ToNot(HaveOccurred()) Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) @@ -220,7 +226,7 @@ var _ = Describe("lastfmAgent", func() { }) It("returns ErrNotAuthorized if user is not linked", func() { - err := agent.NowPlaying(ctx, "user-2", track) + err := agent.NowPlaying(ctx, "user-2", track, 0) Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) }) }) @@ -247,6 +253,23 @@ var _ = Describe("lastfmAgent", func() { Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10))) }) + When("ScrobbleFirstArtistOnly is true", func() { + BeforeEach(func() { + conf.Server.LastFM.ScrobbleFirstArtistOnly = true + }) + + It("uses only the first artist", func() { + ts := time.Now() + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts}) + + Expect(err).ToNot(HaveOccurred()) + sentParams := httpClient.SavedRequest.URL.Query() + Expect(sentParams.Get("artist")).To(Equal("First Artist")) + }) + }) + It("skips songs with less than 31 seconds", func() { track.Duration = 29 httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} @@ -322,24 +345,6 @@ var _ = Describe("lastfmAgent", func() { MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Read more on Last.fm.", URL: "https://www.last.fm/music/Cher/Believe", - Images: []agents.ExternalImage{ - { - URL: "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png", - Size: 34, - }, - { - URL: "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png", - Size: 64, - }, - { - URL: "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png", - Size: 174, - }, - { - URL: "https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png", - Size: 300, - }, - }, })) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) @@ -349,9 +354,8 @@ var _ = Describe("lastfmAgent", func() { f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json") httpClient.Res = http.Response{Body: f, StatusCode: 200} Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{ - Name: "The Definitive Less Damage And More Joy", - URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy", - Images: []agents.ExternalImage{}, + Name: "The Definitive Less Damage And More Joy", + URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy", })) Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy")) diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go index 200e9f63c..769b0f5a6 100644 --- a/core/agents/listenbrainz/agent.go +++ b/core/agents/listenbrainz/agent.go @@ -73,7 +73,7 @@ func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo { return li } -func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { +func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { sk, err := l.sessionKeys.Get(ctx, userId) if err != nil || sk == "" { return errors.Join(err, scrobbler.ErrNotAuthorized) diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go index 86a95d5bf..e99b442de 100644 --- a/core/agents/listenbrainz/agent_test.go +++ b/core/agents/listenbrainz/agent_test.go @@ -79,12 +79,12 @@ var _ = Describe("listenBrainzAgent", func() { It("updates NowPlaying successfully", func() { httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} - err := agent.NowPlaying(ctx, "user-1", track) + err := agent.NowPlaying(ctx, "user-1", track, 0) Expect(err).ToNot(HaveOccurred()) }) It("returns ErrNotAuthorized if user is not linked", func() { - err := agent.NowPlaying(ctx, "user-2", track) + err := agent.NowPlaying(ctx, "user-2", track, 0) Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) }) }) diff --git a/core/archiver.go b/core/archiver.go index a15d0d713..63459816e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -98,7 +98,7 @@ func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error return model.ErrNotAuthorized } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks) + return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { @@ -109,15 +109,40 @@ func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bi } mfs := pls.MediaFiles() log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs)) - return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs) + return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true) } -func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error { +func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error { z := createZipWriter(out, format, bitrate) + + zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { file := a.playlistFilename(mf, format, idx) _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + mf.Path = file + zippedMfs[idx] = mf } + + // Add M3U file if requested + if addM3U && len(zippedMfs) > 0 { + plsName := sanitizeName(name) + w, err := z.CreateHeader(&zip.FileHeader{ + Name: plsName + ".m3u", + Modified: mfs[0].UpdatedAt, + Method: zip.Store, + }) + if err != nil { + log.Error(ctx, "Error creating playlist zip entry", err) + return err + } + + _, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false))) + if err != nil { + log.Error(ctx, "Error writing m3u in zip", err) + return err + } + } + err := z.Close() if err != nil { log.Error(ctx, "Error closing zip file", "id", id, err) diff --git a/core/archiver_test.go b/core/archiver_test.go index f1db5520f..37c4ef9ab 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -145,9 +145,21 @@ var _ = Describe("Archiver", func() { zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) Expect(err).To(BeNil()) - Expect(len(zr.File)).To(Equal(2)) + Expect(len(zr.File)).To(Equal(3)) Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3")) Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3")) + Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u")) + + // Verify M3U content + m3uFile, err := zr.File[2].Open() + Expect(err).To(BeNil()) + defer m3uFile.Close() + + m3uContent, err := io.ReadAll(m3uFile) + Expect(err).To(BeNil()) + + expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n" + Expect(string(m3uContent)).To(Equal(expectedM3U)) }) }) }) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 487346b4d..cb029a16e 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -20,6 +20,12 @@ import ( "github.com/navidrome/navidrome/utils/str" ) +const ( + // maxArtistFolderTraversalDepth defines how many directory levels to search + // when looking for artist images (artist folder + parent directories) + maxArtistFolderTraversalDepth = 3 +) + type artistReader struct { cacheKey a *artwork @@ -108,36 +114,52 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { - fsys := os.DirFS(artistFolder) - matches, err := fs.Glob(fsys, pattern) - if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder) - return nil, "", err - } - if len(matches) == 0 { - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder) - } - for _, m := range matches { - filePath := filepath.Join(artistFolder, m) - if !model.IsImageFile(m) { - continue + current := artistFolder + for i := 0; i < maxArtistFolderTraversalDepth; i++ { + if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { + return reader, path, nil } - f, err := os.Open(filePath) - if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", filePath, err) - return nil, "", err + + parent := filepath.Dir(current) + if parent == current { + break } - return f, filePath, nil + current = parent } - return nil, "", nil + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder) } } +func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder) + fsys := os.DirFS(folder) + matches, err := fs.Glob(fsys, pattern) + if err != nil { + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err) + return nil, "", err + } + + for _, m := range matches { + if !model.IsImageFile(m) { + continue + } + filePath := filepath.Join(folder, m) + f, err := os.Open(filePath) + if err != nil { + log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + continue + } + return f, filePath, nil + } + + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder) +} + func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { if len(albums) == 0 { return "", time.Time{}, nil } - libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library + libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries folderPath := str.LongestCommonPrefix(paths) if !strings.HasSuffix(folderPath, string(filepath.Separator)) { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 294a5db0b..527b0849f 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -3,6 +3,8 @@ package artwork import ( "context" "errors" + "io" + "os" "path/filepath" "time" @@ -108,6 +110,254 @@ var _ = Describe("artistArtworkReader", func() { }) }) }) + + var _ = Describe("fromArtistFolder", func() { + var ( + ctx context.Context + tempDir string + testFunc sourceFunc + ) + + BeforeEach(func() { + ctx = context.Background() + tempDir = GinkgoT().TempDir() + }) + + When("artist folder contains matching image", func() { + BeforeEach(func() { + // Create test structure: /temp/artist/artist.jpg + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds and returns the image", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + + // Verify we can read the content + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + reader.Close() + }) + }) + + When("artist folder is empty but parent contains image", func() { + BeforeEach(func() { + // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ + parentDir := filepath.Join(tempDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + albumDir := filepath.Join(artistDir, "album") + Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) + + // Put artist image in parent directory + artistImagePath := filepath.Join(parentDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds image in parent directory", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("parent image")) + reader.Close() + }) + }) + + When("image is two levels up", func() { + BeforeEach(func() { + // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/ + grandparentDir := filepath.Join(tempDir, "grandparent") + parentDir := filepath.Join(grandparentDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Put artist image in grandparent directory + artistImagePath := filepath.Join(grandparentDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds image in grandparent directory", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("grandparent image")) + reader.Close() + }) + }) + + When("images exist at multiple levels", func() { + BeforeEach(func() { + // Create test structure with images at multiple levels + grandparentDir := filepath.Join(tempDir, "grandparent") + parentDir := filepath.Join(grandparentDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Put artist images at all levels + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("prioritizes the closest (artist folder) image", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist level")) + reader.Close() + }) + }) + + When("pattern matches multiple files", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create multiple matching files + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("returns the first valid image file", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + + // Should return an image file, not the text file + Expect(path).To(SatisfyAny( + ContainSubstring("artist.jpg"), + ContainSubstring("artist.png"), + )) + Expect(path).ToNot(ContainSubstring("artist.txt")) + reader.Close() + }) + }) + + When("no matching files exist anywhere", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create non-matching files + Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("returns an error", func() { + reader, path, err := testFunc() + Expect(err).To(HaveOccurred()) + Expect(reader).To(BeNil()) + Expect(path).To(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'")) + Expect(err.Error()).To(ContainSubstring("parent directories")) + }) + }) + + When("directory traversal reaches filesystem root", func() { + BeforeEach(func() { + // Start from a shallow directory to test root boundary + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("handles root boundary gracefully", func() { + reader, path, err := testFunc() + Expect(err).To(HaveOccurred()) + Expect(reader).To(BeNil()) + Expect(path).To(BeEmpty()) + // Should not panic or cause infinite loop + }) + }) + + When("file exists but cannot be opened", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create a file that cannot be opened (permission denied) + restrictedFile := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("logs warning and continues searching", func() { + // This test depends on the ability to restrict file permissions + // For now, we'll just ensure it doesn't panic and returns appropriate error + reader, _, err := testFunc() + // The file should be readable in test environment, so this will succeed + // In a real scenario with permission issues, it would continue searching + if err == nil { + Expect(reader).ToNot(BeNil()) + reader.Close() + } + }) + }) + + When("single album artist scenario (original issue)", func() { + BeforeEach(func() { + // Simulate the exact folder structure from the issue: + // /music/artist/album1/ (single album) + // /music/artist/artist.jpg (artist image that should be found) + artistDir := filepath.Join(tempDir, "music", "artist") + albumDir := filepath.Join(artistDir, "album1") + Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) + + // Create artist.jpg in the artist folder (this was not being found before) + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) + + // The fromArtistFolder is called with the artist folder path + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds artist.jpg in artist folder for single album artist", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + Expect(path).To(ContainSubstring("artist")) + + // Verify the content + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("single album artist image")) + reader.Close() + }) + }) + }) }) type fakeFolderRepo struct { diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index 367437815..29975e5c5 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -190,10 +190,13 @@ type mockAgents struct { topSongsAgent agents.ArtistTopSongsRetriever similarAgent agents.ArtistSimilarRetriever imageAgent agents.ArtistImageRetriever - albumInfoAgent agents.AlbumInfoRetriever - bioAgent agents.ArtistBiographyRetriever - mbidAgent agents.ArtistMBIDRetriever - urlAgent agents.ArtistURLRetriever + albumInfoAgent interface { + agents.AlbumInfoRetriever + agents.AlbumImageRetriever + } + bioAgent agents.ArtistBiographyRetriever + mbidAgent agents.ArtistMBIDRetriever + urlAgent agents.ArtistURLRetriever agents.Interface } @@ -268,3 +271,14 @@ func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string) } return nil, args.Error(1) } + +func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + if m.albumInfoAgent != nil { + return m.albumInfoAgent.GetAlbumImages(ctx, name, artist, mbid) + } + args := m.Called(ctx, name, artist, mbid) + if args.Get(0) != nil { + return args.Get(0).([]agents.ExternalImage), args.Error(1) + } + return nil, args.Error(1) +} diff --git a/core/external/provider.go b/core/external/provider.go index f27ded11b..8e9a458c1 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -3,6 +3,7 @@ package external import ( "context" "errors" + "fmt" "net/url" "sort" "strings" @@ -11,6 +12,7 @@ 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" @@ -34,7 +36,7 @@ const ( type Provider interface { UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) - SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) + ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) ArtistImage(ctx context.Context, id string) (*url.URL, error) AlbumImage(ctx context.Context, id string) (*url.URL, error) @@ -59,6 +61,7 @@ type auxArtist struct { type Agents interface { agents.AlbumInfoRetriever + agents.AlbumImageRetriever agents.ArtistBiographyRetriever agents.ArtistMBIDRetriever agents.ArtistImageRetriever @@ -139,19 +142,20 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl album.Description = info.Description } - if len(info.Images) > 0 { - sort.Slice(info.Images, func(i, j int) bool { - return info.Images[i].Size > info.Images[j].Size + images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) + if err == nil && len(images) > 0 { + sort.Slice(images, func(i, j int) bool { + return images[i].Size > images[j].Size }) - album.LargeImageUrl = info.Images[0].URL + album.LargeImageUrl = images[0].URL - if len(info.Images) >= 2 { - album.MediumImageUrl = info.Images[1].URL + if len(images) >= 2 { + album.MediumImageUrl = images[1].URL } - if len(info.Images) >= 3 { - album.SmallImageUrl = info.Images[2].URL + if len(images) >= 3 { + album.SmallImageUrl = images[2].URL } } @@ -257,7 +261,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, nil } -func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { +func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) { artist, err := e.getArtist(ctx, id) if err != nil { return nil, err @@ -265,14 +269,14 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode e.callGetSimilar(ctx, e.ag, &artist, 15, false) if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) return nil, ctx.Err() } weightedSongs := random.NewWeightedChooser[model.MediaFile]() addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) return ctx.Err() } @@ -340,29 +344,28 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) return nil, err } - info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) + images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) if err != nil { switch { case errors.Is(err, agents.ErrNotFound): log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) return nil, model.ErrNotFound case errors.Is(err, context.Canceled): - log.Debug(ctx, "GetAlbumInfo call canceled", err) + log.Debug(ctx, "GetAlbumImages call canceled", err) default: - log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err) + log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err) } - return nil, err } - if info == nil { - log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) + if len(images) == 0 { + log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) return nil, model.ErrNotFound } // Return the biggest image var img agents.ExternalImage - for _, i := range info.Images { + for _, i := range images { if img.Size <= i.Size { img = i } @@ -400,20 +403,21 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) ( func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err) } - var mfs model.MediaFiles - for _, t := range songs { - mf, err := e.findMatchingTrack(ctx, t.MBID, artist.ID, t.Name) - if err != nil { - continue - } - mfs = append(mfs, *mf) - if len(mfs) == count { - break - } + 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) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + + log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches)) + mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count) + if len(mfs) == 0 { log.Debug(ctx, "No matching top songs found", "name", artist.Name) } else { @@ -423,35 +427,94 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) { - if mbid != "" { - mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"mbz_recording_id": mbid}, - squirrel.Eq{"missing": false}, - }, - }) - if err == nil && len(mfs) > 0 { - return &mfs[0], nil +func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { + var mbids []string + for _, s := range songs { + if s.MBID != "" { + mbids = append(mbids, s.MBID) } - return e.findMatchingTrack(ctx, "", artistID, title) } - mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + matches := map[string]model.MediaFile{} + if len(mbids) == 0 { + return matches, nil + } + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"mbz_recording_id": mbids}, + squirrel.Eq{"missing": false}, + }, + }) + if err != nil { + return matches, err + } + for _, mf := range res { + if id := mf.MbzRecordingID; id != "" { + if _, ok := matches[id]; !ok { + matches[id] = mf + } + } + } + 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) { + titleMap := map[string]string{} + for _, s := range songs { + if s.MBID != "" && mbidMatches[s.MBID].ID != "" { + continue + } + sanitized := str.SanitizeFieldForSorting(s.Name) + titleMap[sanitized] = s.Name + } + matches := map[string]model.MediaFile{} + if len(titleMap) == 0 { + return matches, nil + } + titleFilters := squirrel.Or{} + for sanitized := range titleMap { + titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized}) + } + + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Or{ - squirrel.Eq{"artist_id": artistID}, - squirrel.Eq{"album_artist_id": artistID}, + squirrel.Eq{"artist_id": artist.ID}, + squirrel.Eq{"album_artist_id": artist.ID}, }, - squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)}, + titleFilters, squirrel.Eq{"missing": false}, }, Sort: "starred desc, rating desc, year asc, compilation asc ", - Max: 1, }) - if err != nil || len(mfs) == 0 { - return nil, model.ErrNotFound + if err != nil { + return matches, err } - return &mfs[0], nil + for _, mf := range res { + sanitized := str.SanitizeFieldForSorting(mf.Title) + if _, ok := matches[sanitized]; !ok { + matches[sanitized] = mf + } + } + return matches, nil +} + +func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles { + var mfs model.MediaFiles + for _, t := range songs { + if len(mfs) == count { + break + } + if t.MBID != "" { + if mf, ok := byMBID[t.MBID]; ok { + mfs = append(mfs, mf) + continue + } + } + if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok { + mfs = append(mfs, mf) + } + } + return mfs } func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { @@ -497,7 +560,7 @@ func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimila return } start := time.Now() - sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent) + sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent) log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start)) if err != nil { return @@ -505,7 +568,7 @@ func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimila artist.SimilarArtists = sa } -func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) { +func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) { var result model.Artists var notPresent []string @@ -528,21 +591,33 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis artistMap[artist.Name] = artist } + count := 0 + // Process the similar artists for _, s := range similar { if artist, found := artistMap[s.Name]; found { result = append(result, artist) + count++ + + if count >= limit { + break + } } else { notPresent = append(notPresent, s.Name) } } // Then fill up with non-present artists - if includeNotPresent { + if includeNotPresent && count < limit { for _, s := range notPresent { // Let the ID empty to indicate that the artist is not present in the DB sa := model.Artist{Name: s} result = append(result, sa) + + count++ + if count >= limit { + break + } } } diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go index e248813c1..9b682462d 100644 --- a/core/external/provider_albumimage_test.go +++ b/core/external/provider_albumimage_test.go @@ -23,7 +23,6 @@ var _ = Describe("Provider - AlbumImage", func() { var mockAlbumRepo *mockAlbumRepo var mockMediaFileRepo *mockMediaFileRepo var mockAlbumAgent *mockAlbumInfoAgent - var agentsCombined *mockAgents var ctx context.Context BeforeEach(func() { @@ -43,10 +42,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumAgent = newMockAlbumInfoAgent() - agentsCombined = &mockAgents{ - albumInfoAgent: mockAlbumAgent, - } - + agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} provider = NewProvider(ds, agentsCombined) // Default mocks @@ -66,13 +62,11 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). - Return(&agents.AlbumInfo{ - Images: []agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, }, nil).Once() expectedURL, _ := url.Parse("http://example.com/large.jpg") @@ -82,8 +76,8 @@ var _ = Describe("Provider - AlbumImage", func() { Expect(imgURL).To(Equal(expectedURL)) mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name }) It("returns ErrNotFound if the album is not found in the DB", func() { @@ -99,7 +93,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) }) It("returns the agent error if the agent fails", func() { @@ -109,7 +103,7 @@ var _ = Describe("Provider - AlbumImage", func() { agentErr := errors.New("agent failure") // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist imgURL, err := provider.AlbumImage(ctx, "album-1") @@ -118,7 +112,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist }) It("returns ErrNotFound if the agent returns ErrNotFound", func() { @@ -127,7 +121,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist imgURL, err := provider.AlbumImage(ctx, "album-1") @@ -135,7 +129,7 @@ var _ = Describe("Provider - AlbumImage", func() { Expect(imgURL).To(BeNil()) mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist }) It("returns ErrNotFound if the agent returns no images", func() { @@ -144,8 +138,8 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). - Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist imgURL, err := provider.AlbumImage(ctx, "album-1") @@ -153,7 +147,7 @@ var _ = Describe("Provider - AlbumImage", func() { Expect(imgURL).To(BeNil()) mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist }) It("returns context error if context is canceled", func() { @@ -163,7 +157,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Expect the agent call even if context is cancelled, returning the context error - mockAlbumAgent.On("GetAlbumInfo", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() + mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() // Cancel the context *before* calling the function under test cancelCtx() @@ -174,7 +168,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") // Agent should now be called, verify this expectation - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", cctx, "Album One", "", "") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "") }) It("derives album ID from MediaFile ID", func() { @@ -186,13 +180,11 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). - Return(&agents.AlbumInfo{ - Images: []agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, }, nil).Once() expectedURL, _ := url.Parse("http://example.com/large.jpg") @@ -206,7 +198,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") }) It("handles different image orders from agent", func() { @@ -214,13 +206,11 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). - Return(&agents.AlbumInfo{ - Images: []agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/small.jpg", Size: 200}, + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, }, nil).Once() expectedURL, _ := url.Parse("http://example.com/large.jpg") @@ -228,7 +218,7 @@ var _ = Describe("Provider - AlbumImage", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") }) It("handles agent returning only one image", func() { @@ -236,11 +226,9 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", ""). - Return(&agents.AlbumInfo{ - Images: []agents.ExternalImage{ - {URL: "http://example.com/single.jpg", Size: 700}, - }, + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/single.jpg", Size: 700}, }, nil).Once() expectedURL, _ := url.Parse("http://example.com/single.jpg") @@ -248,7 +236,7 @@ var _ = Describe("Provider - AlbumImage", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgURL).To(Equal(expectedURL)) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") }) It("returns ErrNotFound if deriving album ID fails", func() { @@ -270,14 +258,15 @@ var _ = Describe("Provider - AlbumImage", func() { mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) }) }) // mockAlbumInfoAgent implementation type mockAlbumInfoAgent struct { mock.Mock - agents.AlbumInfoRetriever // Embed interface + agents.AlbumInfoRetriever + agents.AlbumImageRetriever } func newMockAlbumInfoAgent() *mockAlbumInfoAgent { @@ -299,5 +288,14 @@ func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbi return args.Get(0).(*agents.AlbumInfo), args.Error(1) } -// Ensure mockAgent implements the interface +func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + args := m.Called(ctx, name, artist, mbid) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]agents.ExternalImage), args.Error(1) +} + +// Ensure mockAgent implements the interfaces var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) +var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_artistradio_test.go similarity index 85% rename from core/external/provider_similarsongs_test.go rename to core/external/provider_artistradio_test.go index fd622746a..21ea07706 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_artistradio_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/mock" ) -var _ = Describe("Provider - SimilarSongs", func() { +var _ = Describe("Provider - ArtistRadio", func() { var ds model.DataStore var provider Provider var mockAgent *mockSimilarArtistAgent @@ -50,9 +50,9 @@ var _ = Describe("Provider - SimilarSongs", func() { It("returns similar songs from main artist and similar artists", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} - song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} + song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() @@ -82,11 +82,10 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Song Three", MBID: "mbid-3"}, }, nil).Once() - mediaFileRepo.FindByMBID("mbid-1", song1) - mediaFileRepo.FindByMBID("mbid-2", song2) - mediaFileRepo.FindByMBID("mbid-3", song3) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 3) + songs, err := provider.ArtistRadio(ctx, "artist-1", 3) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) @@ -103,7 +102,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{}, nil).Maybe() - songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5) + songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5) Expect(err).To(Equal(model.ErrNotFound)) Expect(songs).To(BeNil()) @@ -111,7 +110,7 @@ var _ = Describe("Provider - SimilarSongs", func() { It("returns songs from main artist when GetSimilarArtists returns error", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { @@ -130,9 +129,9 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Song One", MBID: "mbid-1"}, }, nil).Once() - mediaFileRepo.FindByMBID("mbid-1", song1) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + songs, err := provider.ArtistRadio(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -157,7 +156,7 @@ var _ = Describe("Provider - SimilarSongs", func() { mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, errors.New("error getting top songs")).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + songs, err := provider.ArtistRadio(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(BeEmpty()) @@ -165,8 +164,8 @@ var _ = Describe("Provider - SimilarSongs", func() { It("respects count parameter", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { @@ -186,10 +185,9 @@ var _ = Describe("Provider - SimilarSongs", func() { {Name: "Song Two", MBID: "mbid-2"}, }, nil).Once() - mediaFileRepo.FindByMBID("mbid-1", song1) - mediaFileRepo.FindByMBID("mbid-2", song2) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 1) + songs, err := provider.ArtistRadio(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 4ce7911de..5a5a25714 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -42,10 +42,6 @@ var _ = Describe("Provider - TopSongs", func() { p = NewProvider(ds, ag) }) - BeforeEach(func() { - // Setup expectations in individual tests - }) - It("returns top songs for a known artist", func() { // Mock finding the artist artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} @@ -58,11 +54,10 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Mock finding matching tracks + // Mock finding matching tracks (both returned in a single query) song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -155,11 +150,10 @@ var _ = Describe("Provider - TopSongs", func() { } ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - // Mock finding matching tracks (only find song 1) + // Mock finding matching tracks (only find song 1 on bulk query) 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() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For mbid-song-2 (fails) - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For title fallback (fails) + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2 songs, err := p.TopSongs(ctx, "Artist One", 2) @@ -190,4 +184,91 @@ var _ = Describe("Provider - TopSongs", func() { artistRepo.AssertExpectations(GinkgoT()) ag.AssertExpectations(GinkgoT()) }) + + It("falls back to title matching when MbzRecordingID is missing", 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 songs that have NO MBID (empty string) + agentSongs := []agents.Song{ + {Name: "Song One", MBID: ""}, // No MBID, should fall back to title matching + {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() + + // Since there are no MBIDs, loadTracksByMBID should not make any database call + // loadTracksByTitle should make a database call for title matching + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + 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("combines MBID and title matching when some songs have missing MbzRecordingID", 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 mixed MBID availability + agentSongs := []agents.Song{ + {Name: "Song One", MBID: "mbid-song-1"}, // Has MBID, should match by MBID + {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() + + // Mock the MBID query (finds song1 by MBID) + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() + + // Mock the title fallback query (finds song2 by title) + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{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")) // Found by MBID + Expect(songs[1].ID).To(Equal("song-2")) // Found by title + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + + It("only returns requested count when provider returns additional items", 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 + agentSongs := []agents.Song{ + {Name: "Song One", MBID: "mbid-song-1"}, + {Name: "Song Two", MBID: "mbid-song-2"}, + } + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() + + // Mock finding matching tracks (both returned in a single query) + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, 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()) + }) }) diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 0622849f0..5f5d41a87 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -59,13 +59,13 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { expectedInfo := &agents.AlbumInfo{ URL: "http://example.com/album", Description: "Album Description", - Images: []agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 300}, - {URL: "http://example.com/medium.jpg", Size: 200}, - {URL: "http://example.com/small.jpg", Size: 100}, - }, } ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil) + ag.On("GetAlbumImages", ctx, "Test Album", "Test Artist", "mbid-album").Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 300}, + {URL: "http://example.com/medium.jpg", Size: 200}, + {URL: "http://example.com/small.jpg", Size: 100}, + }, nil) updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing") @@ -74,9 +74,6 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { Expect(updatedAlbum.ID).To(Equal("al-existing")) Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album")) Expect(updatedAlbum.Description).To(Equal("Album Description")) - Expect(updatedAlbum.LargeImageUrl).To(Equal("http://example.com/large.jpg")) - Expect(updatedAlbum.MediumImageUrl).To(Equal("http://example.com/medium.jpg")) - Expect(updatedAlbum.SmallImageUrl).To(Equal("http://example.com/small.jpg")) Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil()) Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second)) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f1b383977..6bb63b62c 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -184,6 +184,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableCoverAnimation = conf.Server.EnableCoverAnimation + data.Config.EnableNowPlaying = conf.Server.EnableNowPlaying data.Config.EnableDownloads = conf.Server.EnableDownloads data.Config.EnableSharing = conf.Server.EnableSharing data.Config.EnableStarRating = conf.Server.EnableStarRating diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go index 3fb91e7fb..8fd8ed3b7 100644 --- a/core/metrics/insights/data.go +++ b/core/metrics/insights/data.go @@ -61,6 +61,7 @@ type Data struct { EnableJukebox bool `json:"enableJukebox,omitempty"` EnablePrometheus bool `json:"enablePrometheus,omitempty"` EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` SessionTimeout uint64 `json:"sessionTimeout,omitempty"` SearchFullString bool `json:"searchFullString,omitempty"` RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` diff --git a/core/metrics/prometheus.go b/core/metrics/prometheus.go index 5dabf29ce..412483156 100644 --- a/core/metrics/prometheus.go +++ b/core/metrics/prometheus.go @@ -2,7 +2,6 @@ package metrics import ( "context" - "fmt" "net/http" "strconv" "sync" @@ -13,6 +12,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/singleton" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) @@ -20,6 +20,8 @@ import ( type Metrics interface { WriteInitialMetrics(ctx context.Context) WriteAfterScanMetrics(ctx context.Context, success bool) + RecordRequest(ctx context.Context, endpoint, method, client string, status int32, elapsed int64) + RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64) GetHandler() http.Handler } @@ -27,11 +29,14 @@ type metrics struct { ds model.DataStore } -func NewPrometheusInstance(ds model.DataStore) Metrics { - if conf.Server.Prometheus.Enabled { - return &metrics{ds: ds} +func GetPrometheusInstance(ds model.DataStore) Metrics { + if !conf.Server.Prometheus.Enabled { + return noopMetrics{} } - return noopMetrics{} + + return singleton.GetInstance(func() *metrics { + return &metrics{ds: ds} + }) } func NewNoopInstance() Metrics { @@ -51,6 +56,38 @@ func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) { getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc() } +func (m *metrics) RecordRequest(_ context.Context, endpoint, method, client string, status int32, elapsed int64) { + httpLabel := prometheus.Labels{ + "endpoint": endpoint, + "method": method, + "client": client, + "status": strconv.FormatInt(int64(status), 10), + } + getPrometheusMetrics().httpRequestCounter.With(httpLabel).Inc() + + httpLatencyLabel := prometheus.Labels{ + "endpoint": endpoint, + "method": method, + "client": client, + } + getPrometheusMetrics().httpRequestDuration.With(httpLatencyLabel).Observe(float64(elapsed)) +} + +func (m *metrics) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) { + pluginLabel := prometheus.Labels{ + "plugin": plugin, + "method": method, + "ok": strconv.FormatBool(ok), + } + getPrometheusMetrics().pluginRequestCounter.With(pluginLabel).Inc() + + pluginLatencyLabel := prometheus.Labels{ + "plugin": plugin, + "method": method, + } + getPrometheusMetrics().pluginRequestDuration.With(pluginLatencyLabel).Observe(float64(elapsed)) +} + func (m *metrics) GetHandler() http.Handler { r := chi.NewRouter() @@ -59,20 +96,31 @@ func (m *metrics) GetHandler() http.Handler { consts.PrometheusAuthUser: conf.Server.Prometheus.Password, })) } - r.Handle("/", promhttp.Handler()) + // Enable created at timestamp to handle zero counter on create. + // This requires --enable-feature=created-timestamp-zero-ingestion to be passed in Prometheus + r.Handle("/", promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ + EnableOpenMetrics: true, + EnableOpenMetricsTextCreatedSamples: true, + })) return r } type prometheusMetrics struct { - dbTotal *prometheus.GaugeVec - versionInfo *prometheus.GaugeVec - lastMediaScan *prometheus.GaugeVec - mediaScansCounter *prometheus.CounterVec + dbTotal *prometheus.GaugeVec + versionInfo *prometheus.GaugeVec + lastMediaScan *prometheus.GaugeVec + mediaScansCounter *prometheus.CounterVec + httpRequestCounter *prometheus.CounterVec + httpRequestDuration *prometheus.SummaryVec + pluginRequestCounter *prometheus.CounterVec + pluginRequestDuration *prometheus.SummaryVec } // Prometheus' metrics requires initialization. But not more than once var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics { + quartilesToEstimate := map[float64]float64{0.5: 0.05, 0.75: 0.025, 0.9: 0.01, 0.99: 0.001} + instance := &prometheusMetrics{ dbTotal: prometheus.NewGaugeVec( prometheus.GaugeOpts{ @@ -102,23 +150,49 @@ var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics { }, []string{"success"}, ), + httpRequestCounter: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_request_count", + Help: "Request types by status", + }, + []string{"endpoint", "method", "client", "status"}, + ), + httpRequestDuration: prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "http_request_latency", + Help: "Latency (in ms) of HTTP requests", + Objectives: quartilesToEstimate, + }, + []string{"endpoint", "method", "client"}, + ), + pluginRequestCounter: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "plugin_request_count", + Help: "Plugin requests by method/status", + }, + []string{"plugin", "method", "ok"}, + ), + pluginRequestDuration: prometheus.NewSummaryVec( + prometheus.SummaryOpts{ + Name: "plugin_request_latency", + Help: "Latency (in ms) of plugin requests", + Objectives: quartilesToEstimate, + }, + []string{"plugin", "method"}, + ), } - err := prometheus.DefaultRegisterer.Register(instance.dbTotal) - if err != nil { - log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register db_model_totals metrics: %w", err)) - } - err = prometheus.DefaultRegisterer.Register(instance.versionInfo) - if err != nil { - log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register navidrome_info metrics: %w", err)) - } - err = prometheus.DefaultRegisterer.Register(instance.lastMediaScan) - if err != nil { - log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scan_last metrics: %w", err)) - } - err = prometheus.DefaultRegisterer.Register(instance.mediaScansCounter) - if err != nil { - log.Fatal("Unable to create Prometheus metric instance", fmt.Errorf("unable to register media_scans metrics: %w", err)) - } + + prometheus.DefaultRegisterer.MustRegister( + instance.dbTotal, + instance.versionInfo, + instance.lastMediaScan, + instance.mediaScansCounter, + instance.httpRequestCounter, + instance.httpRequestDuration, + instance.pluginRequestCounter, + instance.pluginRequestDuration, + ) + return instance }) @@ -159,4 +233,8 @@ func (n noopMetrics) WriteInitialMetrics(context.Context) {} func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {} +func (n noopMetrics) RecordRequest(context.Context, string, string, string, int32, int64) {} + +func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {} + func (n noopMetrics) GetHandler() http.Handler { return nil } diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 495d27512..f356a1410 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -10,11 +10,15 @@ import ( "strings" "sync" + "github.com/kballard/go-shellquote" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" ) func start(ctx context.Context, args []string) (Executor, error) { + if len(args) == 0 { + return Executor{}, fmt.Errorf("no command arguments provided") + } log.Debug("Executing mpv command", "cmd", args) j := Executor{args: args} j.PipeReader, j.out = io.Pipe() @@ -71,28 +75,32 @@ func (j *Executor) wait() { // Path will always be an absolute path func createMPVCommand(deviceName string, filename string, socketName string) []string { - split := strings.Split(fixCmd(conf.Server.MPVCmdTemplate), " ") - for i, s := range split { - s = strings.ReplaceAll(s, "%d", deviceName) - s = strings.ReplaceAll(s, "%f", filename) - s = strings.ReplaceAll(s, "%s", socketName) - split[i] = s + // Parse the template structure using shell parsing to handle quoted arguments + templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate) + if err != nil { + log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err) + return nil } - return split -} -func fixCmd(cmd string) string { - split := strings.Split(cmd, " ") - var result []string - cmdPath, _ := mpvCommand() - for _, s := range split { - if s == "mpv" || s == "mpv.exe" { - result = append(result, cmdPath) - } else { - result = append(result, s) + // Replace placeholders in each parsed argument to preserve spaces in substituted values + for i, arg := range templateArgs { + arg = strings.ReplaceAll(arg, "%d", deviceName) + arg = strings.ReplaceAll(arg, "%f", filename) + arg = strings.ReplaceAll(arg, "%s", socketName) + templateArgs[i] = arg + } + + // Replace mpv executable references with the configured path + if len(templateArgs) > 0 { + cmdPath, err := mpvCommand() + if err == nil { + if templateArgs[0] == "mpv" || templateArgs[0] == "mpv.exe" { + templateArgs[0] = cmdPath + } } } - return strings.Join(result, " ") + + return templateArgs } // This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified. diff --git a/core/playback/mpv/mpv_suite_test.go b/core/playback/mpv/mpv_suite_test.go new file mode 100644 index 000000000..f8f827620 --- /dev/null +++ b/core/playback/mpv/mpv_suite_test.go @@ -0,0 +1,17 @@ +package mpv + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMPV(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "MPV Suite") +} diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go new file mode 100644 index 000000000..08432bef3 --- /dev/null +++ b/core/playback/mpv/mpv_test.go @@ -0,0 +1,390 @@ +package mpv + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MPV", func() { + var ( + testScript string + tempDir string + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + // Reset MPV cache + mpvOnce = sync.Once{} + mpvPath = "" + mpvErr = nil + + // Create temporary directory for test files + var err error + tempDir, err = os.MkdirTemp("", "mpv_test_*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { os.RemoveAll(tempDir) }) + + // Create mock MPV script that outputs arguments to stdout + testScript = createMockMPVScript(tempDir) + + // Configure test MPV path + conf.Server.MPVPath = testScript + }) + + Describe("createMPVCommand", func() { + Context("with default template", func() { + BeforeEach(func() { + conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s" + }) + + It("creates correct command with simple paths", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{ + testScript, + "--audio-device=auto", + "--no-audio-display", + "--pause", + "/music/test.mp3", + "--input-ipc-server=/tmp/socket", + })) + }) + + It("handles paths with spaces", func() { + args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{ + testScript, + "--audio-device=auto", + "--no-audio-display", + "--pause", + "/music/My Album/01 - Song.mp3", + "--input-ipc-server=/tmp/socket", + })) + }) + + It("handles complex device names", func() { + deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1" + args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{ + testScript, + "--audio-device=" + deviceName, + "--no-audio-display", + "--pause", + "/music/test.mp3", + "--input-ipc-server=/tmp/socket", + })) + }) + }) + + Context("with snapcast template (issue #3619)", func() { + BeforeEach(func() { + // This is the template that fails with naive space splitting + conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo" + }) + + It("creates correct command for snapcast integration", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{ + testScript, + "--no-audio-display", + "--pause", + "/music/test.mp3", + "--input-ipc-server=/tmp/socket", + "--audio-channels=stereo", + "--audio-samplerate=48000", + "--audio-format=s16", + "--ao=pcm", + "--ao-pcm-file=/audio/snapcast_fifo", + })) + }) + }) + + Context("with wrapper script template", func() { + BeforeEach(func() { + // Test case that would break with naive splitting due to quoted arguments + conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo` + }) + + It("handles wrapper script paths", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{ + "/tmp/mpv.sh", + "--no-audio-display", + "--pause", + "/music/test.mp3", + "--input-ipc-server=/tmp/socket", + "--audio-channels=stereo", + })) + }) + }) + + Context("with extra spaces in template", func() { + BeforeEach(func() { + conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s" + }) + + It("handles extra spaces correctly", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{ + testScript, + "--audio-device=auto", + "--no-audio-display", + "--pause", + "/music/test.mp3", + "--input-ipc-server=/tmp/socket", + })) + }) + }) + Context("with paths containing spaces in template arguments", func() { + BeforeEach(func() { + // Template with spaces in the path arguments themselves + conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s` + }) + + It("handles spaces in quoted template argument paths", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + // This test reveals the limitation of strings.Fields() - it will split on all spaces + // Expected behavior would be to keep the path as one argument + Expect(args).To(Equal([]string{ + testScript, + "--no-audio-display", + "--pause", + "/music/test.mp3", + "--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument + "--input-ipc-server=/tmp/socket", + })) + }) + }) + + Context("with malformed template", func() { + BeforeEach(func() { + // Template with unmatched quotes that will cause shell parsing to fail + conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote` + }) + + It("returns nil when shell parsing fails", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + Expect(args).To(BeNil()) + }) + }) + + Context("with empty template", func() { + BeforeEach(func() { + conf.Server.MPVCmdTemplate = "" + }) + + It("returns empty slice for empty template", func() { + args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") + Expect(args).To(Equal([]string{})) + }) + }) + }) + + Describe("start", func() { + BeforeEach(func() { + conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s" + }) + + It("executes MPV command and captures arguments correctly", func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + deviceName := "auto" + filename := "/music/test.mp3" + socketName := "/tmp/test_socket" + + args := createMPVCommand(deviceName, filename, socketName) + executor, err := start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + + // Read all the output from stdout (this will block until the process finishes or is canceled) + output, err := io.ReadAll(executor) + Expect(err).ToNot(HaveOccurred()) + + // Parse the captured arguments + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + Expect(lines).To(HaveLen(6)) + Expect(lines[0]).To(Equal(testScript)) + Expect(lines[1]).To(Equal("--audio-device=auto")) + Expect(lines[2]).To(Equal("--no-audio-display")) + Expect(lines[3]).To(Equal("--pause")) + Expect(lines[4]).To(Equal("/music/test.mp3")) + Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket")) + }) + + It("handles file paths with spaces", func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + deviceName := "auto" + filename := "/music/My Album/01 - My Song.mp3" + socketName := "/tmp/test socket" + + args := createMPVCommand(deviceName, filename, socketName) + executor, err := start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + + // Read all the output from stdout (this will block until the process finishes or is canceled) + output, err := io.ReadAll(executor) + Expect(err).ToNot(HaveOccurred()) + + // Parse the captured arguments + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3")) + Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket")) + }) + + Context("with complex snapcast configuration", func() { + BeforeEach(func() { + conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo" + }) + + It("passes all snapcast arguments correctly", func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + deviceName := "auto" + filename := "/music/album/track.flac" + socketName := "/tmp/mpv-ctrl-test.socket" + + args := createMPVCommand(deviceName, filename, socketName) + executor, err := start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + + // Read all the output from stdout (this will block until the process finishes or is canceled) + output, err := io.ReadAll(executor) + Expect(err).ToNot(HaveOccurred()) + + // Parse the captured arguments + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + + // Verify all expected arguments are present + Expect(lines).To(ContainElement("--no-audio-display")) + Expect(lines).To(ContainElement("--pause")) + Expect(lines).To(ContainElement("/music/album/track.flac")) + Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket")) + Expect(lines).To(ContainElement("--audio-channels=stereo")) + Expect(lines).To(ContainElement("--audio-samplerate=48000")) + Expect(lines).To(ContainElement("--audio-format=s16")) + Expect(lines).To(ContainElement("--ao=pcm")) + Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo")) + }) + }) + + Context("with nil args", func() { + It("returns error when args is nil", func() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + _, err := start(ctx, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("no command arguments provided")) + }) + + It("returns error when args is empty", func() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + _, err := start(ctx, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("no command arguments provided")) + }) + }) + }) + + Describe("mpvCommand", func() { + BeforeEach(func() { + // Reset the mpv command cache + mpvOnce = sync.Once{} + mpvPath = "" + mpvErr = nil + }) + + It("finds the configured MPV path", func() { + conf.Server.MPVPath = testScript + path, err := mpvCommand() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(testScript)) + }) + }) + + Describe("NewTrack integration", func() { + var testMediaFile model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.MPVPath = testScript + + // Create a test media file + testMediaFile = model.MediaFile{ + ID: "test-id", + Path: "/music/test.mp3", + } + }) + + Context("with malformed template", func() { + BeforeEach(func() { + // Template with unmatched quotes that will cause shell parsing to fail + conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote` + }) + + It("returns error when createMPVCommand fails", func() { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + playbackDone := make(chan bool, 1) + _, err := NewTrack(ctx, playbackDone, "auto", testMediaFile) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("no mpv command arguments provided")) + }) + }) + }) +}) + +// createMockMPVScript creates a mock script that outputs arguments to stdout +func createMockMPVScript(tempDir string) string { + var scriptContent string + var scriptExt string + + if runtime.GOOS == "windows" { + scriptExt = ".bat" + scriptContent = `@echo off +echo %0 +:loop +if "%~1"=="" goto end +echo %~1 +shift +goto loop +:end +` + } else { + scriptExt = ".sh" + scriptContent = `#!/bin/bash +echo "$0" +for arg in "$@"; do + echo "$arg" +done +` + } + + scriptPath := filepath.Join(tempDir, "mock_mpv"+scriptExt) + err := os.WriteFile(scriptPath, []byte(scriptContent), 0755) // nolint:gosec + if err != nil { + panic(fmt.Sprintf("Failed to create mock script: %v", err)) + } + + return scriptPath +} diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index b894ff3ad..14170efd4 100644 --- a/core/playback/mpv/track.go +++ b/core/playback/mpv/track.go @@ -34,7 +34,10 @@ func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName str tmpSocketName := socketName("mpv-ctrl-", ".socket") - args := createMPVCommand(deviceName, mf.Path, tmpSocketName) + args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName) + if len(args) == 0 { + return nil, fmt.Errorf("no mpv command arguments provided") + } exe, err := start(ctx, args) if err != nil { log.Error("Error starting mpv process", err) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 047e43eef..4f64a3c2b 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -10,9 +10,16 @@ import ( ) func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler { - b := &bufferedScrobbler{ds: ds, wrapped: s, service: service} - b.wakeSignal = make(chan struct{}, 1) - go b.run(context.TODO()) + ctx, cancel := context.WithCancel(context.Background()) + b := &bufferedScrobbler{ + ds: ds, + wrapped: s, + service: service, + wakeSignal: make(chan struct{}, 1), + ctx: ctx, + cancel: cancel, + } + go b.run(ctx) return b } @@ -21,14 +28,22 @@ type bufferedScrobbler struct { wrapped Scrobbler service string wakeSignal chan struct{} + ctx context.Context + cancel context.CancelFunc +} + +func (b *bufferedScrobbler) Stop() { + if b.cancel != nil { + b.cancel() + } } func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return b.wrapped.IsAuthorized(ctx, userId) } -func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { - return b.wrapped.NowPlaying(ctx, userId, track) +func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { + return b.wrapped.NowPlaying(ctx, userId, track, position) } func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go new file mode 100644 index 000000000..c1440046d --- /dev/null +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -0,0 +1,88 @@ +package scrobbler + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("BufferedScrobbler", func() { + var ds model.DataStore + var scr *fakeScrobbler + var bs *bufferedScrobbler + var ctx context.Context + var buffer *tests.MockedScrobbleBufferRepo + + BeforeEach(func() { + ctx = context.Background() + buffer = tests.CreateMockedScrobbleBufferRepo() + ds = &tests.MockDataStore{ + MockedScrobbleBuffer: buffer, + } + scr = &fakeScrobbler{Authorized: true} + bs = newBufferedScrobbler(ds, scr, "test") + }) + + It("forwards IsAuthorized calls", func() { + scr.Authorized = true + Expect(bs.IsAuthorized(ctx, "user1")).To(BeTrue()) + + scr.Authorized = false + Expect(bs.IsAuthorized(ctx, "user1")).To(BeFalse()) + }) + + It("forwards NowPlaying calls", func() { + track := &model.MediaFile{ID: "123", Title: "Test Track"} + Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed()) + Expect(scr.NowPlayingCalled).To(BeTrue()) + Expect(scr.UserID).To(Equal("user1")) + Expect(scr.Track).To(Equal(track)) + }) + + It("enqueues scrobbles to buffer", func() { + track := model.MediaFile{ID: "123", Title: "Test Track"} + now := time.Now() + scrobble := Scrobble{MediaFile: track, TimeStamp: now} + Expect(buffer.Length()).To(Equal(int64(0))) + Expect(scr.ScrobbleCalled.Load()).To(BeFalse()) + + Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) + Expect(buffer.Length()).To(Equal(int64(1))) + + // Wait for the scrobble to be sent + Eventually(scr.ScrobbleCalled.Load).Should(BeTrue()) + + lastScrobble := scr.LastScrobble.Load() + Expect(lastScrobble.MediaFile.ID).To(Equal("123")) + Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now)) + }) + + It("stops the background goroutine when Stop is called", func() { + // Replace the real run method with one that signals when it exits + done := make(chan struct{}) + + // Start our instrumented run function that will signal when it exits + go func() { + defer close(done) + bs.run(bs.ctx) + }() + + // Wait a bit to ensure the goroutine is running + time.Sleep(10 * time.Millisecond) + + // Call the real Stop method + bs.Stop() + + // Wait for the goroutine to exit or timeout + select { + case <-done: + // Success, goroutine exited + case <-time.After(100 * time.Millisecond): + Fail("Goroutine did not exit in time after Stop was called") + } + }) +}) diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index 90141f112..f8567e91b 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -21,7 +21,7 @@ var ( type Scrobbler interface { IsAuthorized(ctx context.Context, userId string) bool - NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error + NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error Scrobble(ctx context.Context, userId string, s Scrobble) error } diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 53f397647..7ce9522b9 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -2,9 +2,12 @@ package scrobbler import ( "context" + "maps" "sort" + "sync" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -17,6 +20,7 @@ import ( type NowPlayingInfo struct { MediaFile model.MediaFile Start time.Time + Position int Username string PlayerId string PlayerName string @@ -28,30 +32,53 @@ type Submission struct { } type PlayTracker interface { - NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error + NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) Submit(ctx context.Context, submissions []Submission) error } -type playTracker struct { - ds model.DataStore - broker events.Broker - playMap cache.SimpleCache[string, NowPlayingInfo] - scrobblers map[string]Scrobbler +// PluginLoader is a minimal interface for plugin manager usage in PlayTracker +// (avoids import cycles) +type PluginLoader interface { + PluginNames(service string) []string + LoadScrobbler(name string) (Scrobbler, bool) } -func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker { +type playTracker struct { + ds model.DataStore + broker events.Broker + playMap cache.SimpleCache[string, NowPlayingInfo] + builtinScrobblers map[string]Scrobbler + pluginScrobblers map[string]Scrobbler + pluginLoader PluginLoader + mu sync.RWMutex +} + +func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { return singleton.GetInstance(func() *playTracker { - return newPlayTracker(ds, broker) + return newPlayTracker(ds, broker, pluginManager) }) } // This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by // the GetPlayTracker function above -func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker { +func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { m := cache.NewSimpleCache[string, NowPlayingInfo]() - p := &playTracker{ds: ds, playMap: m, broker: broker} - p.scrobblers = make(map[string]Scrobbler) + p := &playTracker{ + ds: ds, + playMap: m, + broker: broker, + builtinScrobblers: make(map[string]Scrobbler), + pluginScrobblers: make(map[string]Scrobbler), + pluginLoader: pluginManager, + } + if conf.Server.EnableNowPlaying { + m.OnExpiration(func(_ string, _ NowPlayingInfo) { + ctx := events.BroadcastToAll(context.Background()) + broker.SendMessage(ctx, &events.NowPlayingCount{Count: m.Len()}) + }) + } + var enabled []string for name, constructor := range constructors { s := constructor(ds) @@ -61,13 +88,92 @@ func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker { } enabled = append(enabled, name) s = newBufferedScrobbler(ds, s, name) - p.scrobblers[name] = s + p.builtinScrobblers[name] = s } - log.Debug("List of scrobblers enabled", "names", enabled) + log.Debug("List of builtin scrobblers enabled", "names", enabled) return p } -func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error { +// 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 + } + for _, name := range pluginNames { + if _, ok := scrobblers[name]; !ok { + return false + } + } + return true +} + +// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers +func (p *playTracker) refreshPluginScrobblers() { + p.mu.Lock() + defer p.mu.Unlock() + if p.pluginLoader == nil { + return + } + + // Get the list of available plugin names + pluginNames := p.pluginLoader.PluginNames("Scrobbler") + + // Early return if plugin names match existing scrobblers (no change) + if pluginNamesMatchScrobblers(pluginNames, p.pluginScrobblers) { + return + } + + // Build a set of current plugins for faster lookups + current := make(map[string]struct{}, len(pluginNames)) + + // Process additions - add new plugins + 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) + } + } + } + + // Process removals - remove plugins that no longer exist + for name, scrobbler := range p.pluginScrobblers { + if _, exists := current[name]; !exists { + // Type assertion to access the Stop method + // We need to ensure this works even with interface objects + if bs, ok := scrobbler.(*bufferedScrobbler); ok { + log.Debug("Stopping buffered scrobbler goroutine", "name", name) + bs.Stop() + } else { + // For tests - try to see if this is a mock with a Stop method + type stoppable interface { + Stop() + } + if s, ok := scrobbler.(stoppable); ok { + log.Debug("Stopping mock scrobbler", "name", name) + s.Stop() + } + } + delete(p.pluginScrobblers, name) + } + } +} + +// getActiveScrobblers refreshes plugin scrobblers, acquires a read lock, +// combines builtin and plugin scrobblers into a new map, releases the lock, +// and returns the combined map. +func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { + p.refreshPluginScrobblers() + p.mu.RLock() + defer p.mu.RUnlock() + combined := maps.Clone(p.builtinScrobblers) + maps.Copy(combined, p.pluginScrobblers) + return combined +} + +func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) if err != nil { log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) @@ -78,31 +184,44 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam info := NowPlayingInfo{ MediaFile: *mf, Start: time.Now(), + Position: position, Username: user.UserName, PlayerId: playerId, PlayerName: playerName, } - ttl := time.Duration(int(mf.Duration)+5) * time.Second + // Calculate TTL based on remaining track duration. If position exceeds track duration, + // remaining is set to 0 to avoid negative TTL. + remaining := int(mf.Duration) - position + if remaining < 0 { + remaining = 0 + } + // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. + ttl := time.Duration(remaining+5) * time.Second _ = p.playMap.AddWithTTL(playerId, info, ttl) + if conf.Server.EnableNowPlaying { + ctx = events.BroadcastToAll(ctx) + p.broker.SendMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) + } player, _ := request.PlayerFrom(ctx) if player.ScrobbleEnabled { - p.dispatchNowPlaying(ctx, user.ID, mf) + p.dispatchNowPlaying(ctx, user.ID, mf, position) } return nil } -func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) { +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { if t.Artist == consts.UnknownArtist { log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) return } - for name, s := range p.scrobblers { + allScrobblers := p.getActiveScrobblers() + for name, s := range allScrobblers { if !s.IsAuthorized(ctx, userId) { continue } - log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist) - err := s.NowPlaying(ctx, userId, t) + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) + err := s.NowPlaying(ctx, userId, t, position) if err != nil { log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) continue @@ -174,9 +293,11 @@ func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) return } + + allScrobblers := p.getActiveScrobblers() u, _ := request.UserFrom(ctx) scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime} - for name, s := range p.scrobblers { + for name, s := range allScrobblers { if !s.IsAuthorized(ctx, u.ID) { continue } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 0ff025f15..0447aa142 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,8 +3,13 @@ package scrobbler import ( "context" "errors" + "net/http" + "sync" + "sync/atomic" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" @@ -15,10 +20,28 @@ import ( . "github.com/onsi/gomega" ) +// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests +// Moved to top-level scope to avoid linter issues + +type mockPluginLoader struct { + names []string + scrobblers map[string]Scrobbler +} + +func (m *mockPluginLoader) PluginNames(service string) []string { + return m.names +} + +func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { + s, ok := m.scrobblers[name] + return s, ok +} + var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore var tracker PlayTracker + var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album var artist1 model.Artist @@ -26,6 +49,7 @@ var _ = Describe("PlayTracker", func() { var fake fakeScrobbler BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) ctx = context.Background() ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) @@ -37,8 +61,9 @@ var _ = Describe("PlayTracker", func() { Register("disabled", func(model.DataStore) Scrobbler { return nil }) - tracker = newPlayTracker(ds, events.GetBroker()) - tracker.(*playTracker).scrobblers["fake"] = &fake // Bypass buffering for tests + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests track = model.MediaFile{ ID: "123", @@ -62,13 +87,13 @@ var _ = Describe("PlayTracker", func() { }) It("does not register disabled scrobblers", func() { - Expect(tracker.(*playTracker).scrobblers).To(HaveKey("fake")) - Expect(tracker.(*playTracker).scrobblers).ToNot(HaveKey("disabled")) + Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) + Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) }) Describe("NowPlaying", func() { It("sends track to agent", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) Expect(err).ToNot(HaveOccurred()) Expect(fake.NowPlayingCalled).To(BeTrue()) Expect(fake.UserID).To(Equal("u-1")) @@ -78,7 +103,7 @@ var _ = Describe("PlayTracker", func() { It("does not send track to agent if user has not authorized", func() { fake.Authorized = false - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) Expect(err).ToNot(HaveOccurred()) Expect(fake.NowPlayingCalled).To(BeFalse()) @@ -86,7 +111,7 @@ var _ = Describe("PlayTracker", func() { It("does not send track to agent if player is not enabled to send scrobbles", func() { ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) Expect(err).ToNot(HaveOccurred()) Expect(fake.NowPlayingCalled).To(BeFalse()) @@ -94,11 +119,40 @@ var _ = Describe("PlayTracker", func() { It("does not send track to agent if artist is unknown", func() { track.Artist = consts.UnknownArtist - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) Expect(err).ToNot(HaveOccurred()) Expect(fake.NowPlayingCalled).To(BeFalse()) }) + + It("stores position when greater than zero", func() { + pos := 42 + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].Position).To(Equal(pos)) + Expect(fake.Position).To(Equal(pos)) + }) + + It("sends event with count", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + eventList := eventBroker.getEvents() + Expect(eventList).ToNot(BeEmpty()) + evt, ok := eventList[0].(*events.NowPlayingCount) + Expect(ok).To(BeTrue()) + Expect(evt.Count).To(Equal(1)) + }) + + It("does not send event when disabled", func() { + conf.Server.EnableNowPlaying = false + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) }) Describe("GetNowPlaying", func() { @@ -107,9 +161,9 @@ var _ = Describe("PlayTracker", func() { track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123") + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"}) - _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456") + _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) playing, err := tracker.GetNowPlaying(ctx) @@ -127,6 +181,26 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("Expiration events", func() { + It("sends event when entry expires", func() { + info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) + eventList := eventBroker.getEvents() + evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) + Expect(ok).To(BeTrue()) + Expect(evt.Count).To(Equal(0)) + }) + + It("does not send event when disabled", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) + Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) + }) + }) + Describe("Submit", func() { It("sends track to agent", func() { ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) @@ -135,10 +209,12 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled).To(BeTrue()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) Expect(fake.UserID).To(Equal("u-1")) - Expect(fake.LastScrobble.ID).To(Equal("123")) - Expect(fake.LastScrobble.Participants).To(Equal(track.Participants)) + lastScrobble := fake.LastScrobble.Load() + Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second)) + Expect(lastScrobble.ID).To(Equal("123")) + Expect(lastScrobble.Participants).To(Equal(track.Participants)) }) It("increments play counts in the DB", func() { @@ -162,7 +238,7 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled).To(BeFalse()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) }) It("does not send track to agent if player is not enabled to send scrobbles", func() { @@ -171,7 +247,7 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled).To(BeFalse()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) }) It("does not send track to agent if artist is unknown", func() { @@ -180,7 +256,7 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled).To(BeFalse()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) }) It("increments play counts even if it cannot scrobble", func() { @@ -189,7 +265,7 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled).To(BeFalse()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) Expect(track.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1))) @@ -200,15 +276,111 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("Plugin scrobbler logic", func() { + var pluginLoader *mockPluginLoader + var pluginFake fakeScrobbler + + BeforeEach(func() { + pluginFake = fakeScrobbler{Authorized: true} + pluginLoader = &mockPluginLoader{ + names: []string{"plugin1"}, + scrobblers: map[string]Scrobbler{"plugin1": &pluginFake}, + } + tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) + + // Bypass buffering for both built-in and plugin scrobblers + tracker.(*playTracker).builtinScrobblers["fake"] = &fake + tracker.(*playTracker).pluginScrobblers["plugin1"] = &pluginFake + }) + + It("registers and uses plugin scrobbler for NowPlaying", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(pluginFake.NowPlayingCalled).To(BeTrue()) + }) + + It("removes plugin scrobbler if not present anymore", func() { + // First call: plugin present + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(pluginFake.NowPlayingCalled).To(BeTrue()) + pluginFake.NowPlayingCalled = false + // Remove plugin + pluginLoader.names = []string{} + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(pluginFake.NowPlayingCalled).To(BeFalse()) + }) + + It("calls both builtin and plugin scrobblers for NowPlaying", func() { + fake.NowPlayingCalled = false + pluginFake.NowPlayingCalled = false + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.NowPlayingCalled).To(BeTrue()) + Expect(pluginFake.NowPlayingCalled).To(BeTrue()) + }) + + It("calls plugin scrobbler for Submit", func() { + ts := time.Now() + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) + Expect(err).ToNot(HaveOccurred()) + Expect(pluginFake.ScrobbleCalled.Load()).To(BeTrue()) + }) + }) + + Describe("Plugin Scrobbler Management", func() { + var pluginScr *fakeScrobbler + var mockPlugin *mockPluginLoader + var pTracker *playTracker + var mockedBS *mockBufferedScrobbler + + BeforeEach(func() { + ctx = context.Background() + ctx = request.WithUser(ctx, model.User{ID: "u-1"}) + ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) + ds = &tests.MockDataStore{} + + // Setup plugin scrobbler + pluginScr = &fakeScrobbler{Authorized: true} + mockPlugin = &mockPluginLoader{ + names: []string{"plugin1"}, + scrobblers: map[string]Scrobbler{"plugin1": pluginScr}, + } + + // Create a tracker with the mock plugin loader + pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin) + + // Create a mock buffered scrobbler and explicitly cast it to Scrobbler + mockedBS = &mockBufferedScrobbler{ + wrapped: pluginScr, + } + // Make sure the instance is added with its concrete type preserved + pTracker.pluginScrobblers["plugin1"] = mockedBS + }) + + It("calls Stop on scrobblers when removing them", func() { + // Change the plugin names to simulate a plugin being removed + mockPlugin.names = []string{} + + // Call refreshPluginScrobblers which should detect the removed plugin + pTracker.refreshPluginScrobblers() + + // Verify the Stop method was called + Expect(mockedBS.stopCalled).To(BeTrue()) + + // Verify the scrobbler was removed from the map + Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1")) + }) + }) }) type fakeScrobbler struct { Authorized bool NowPlayingCalled bool - ScrobbleCalled bool + ScrobbleCalled atomic.Bool UserID string Track *model.MediaFile - LastScrobble Scrobble + Position int + LastScrobble atomic.Pointer[Scrobble] Error error } @@ -216,23 +388,24 @@ func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } -func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { +func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { f.NowPlayingCalled = true if f.Error != nil { return f.Error } f.UserID = userId f.Track = track + f.Position = position return nil } func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { - f.ScrobbleCalled = true + f.UserID = userId + f.LastScrobble.Store(&s) + f.ScrobbleCalled.Store(true) if f.Error != nil { return f.Error } - f.UserID = userId - f.LastScrobble = s return nil } @@ -243,3 +416,45 @@ func _p(id, name string, sortName ...string) model.Participant { } return p } + +type fakeEventBroker struct { + http.Handler + events []events.Event + mu sync.Mutex +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) getEvents() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return f.events +} + +var _ events.Broker = (*fakeEventBroker)(nil) + +// mockBufferedScrobbler used to test that Stop is called +type mockBufferedScrobbler struct { + wrapped Scrobbler + stopCalled bool +} + +func (m *mockBufferedScrobbler) Stop() { + m.stopCalled = true +} + +func (m *mockBufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { + return m.wrapped.IsAuthorized(ctx, userId) +} + +func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { + return m.wrapped.NowPlaying(ctx, userId, track, position) +} + +func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { + return m.wrapped.Scrobble(ctx, userId, s) +} diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go index 251b27f63..fcbef7e4e 100644 --- a/db/migrations/20241026183640_support_new_scanner.go +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -13,7 +13,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/chain" + "github.com/navidrome/navidrome/utils/run" "github.com/pressly/goose/v3" ) @@ -25,7 +25,7 @@ func upSupportNewScanner(ctx context.Context, tx *sql.Tx) error { execute := createExecuteFunc(ctx, tx) addColumn := createAddColumnFunc(ctx, tx) - return chain.RunSequentially( + return run.Sequentially( upSupportNewScanner_CreateTableFolder(ctx, execute), upSupportNewScanner_PopulateTableFolder(ctx, tx), upSupportNewScanner_UpdateTableMediaFile(ctx, execute, addColumn), @@ -213,7 +213,7 @@ update media_file set path = replace(substr(path, %d), '\', '/');`, libPathLen+2 func upSupportNewScanner_UpdateTableMediaFile(_ context.Context, execute execStmtFunc, addColumn addColumnFunc) execFunc { return func() error { - return chain.RunSequentially( + return run.Sequentially( execute(` alter table media_file add column folder_id varchar default '' not null; @@ -288,7 +288,7 @@ create index if not exists album_mbz_release_group_id func upSupportNewScanner_UpdateTableArtist(_ context.Context, execute execStmtFunc, addColumn addColumnFunc) execFunc { return func() error { - return chain.RunSequentially( + return run.Sequentially( execute(` alter table artist drop column album_count; diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go new file mode 100644 index 000000000..d9250eba2 --- /dev/null +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -0,0 +1,80 @@ +package migrations + +import ( + "context" + "database/sql" + "strings" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upPlayQueueCurrentToIndex, downPlayQueueCurrentToIndex) +} + +func upPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +create table playqueue_dg_tmp( + id varchar(255) not null, + user_id varchar(255) not null + references user(id) + on update cascade on delete cascade, + current integer not null default 0, + position real, + changed_by varchar(255), + items varchar(255), + created_at datetime, + updated_at datetime +);`) + if err != nil { + return err + } + + rows, err := tx.QueryContext(ctx, `select id, user_id, current, position, changed_by, items, created_at, updated_at from playqueue`) + if err != nil { + return err + } + defer rows.Close() + + stmt, err := tx.PrepareContext(ctx, `insert into playqueue_dg_tmp(id, user_id, current, position, changed_by, items, created_at, updated_at) values(?,?,?,?,?,?,?,?)`) + if err != nil { + return err + } + defer stmt.Close() + + for rows.Next() { + var id, userID, currentID, changedBy, items string + var position sql.NullFloat64 + var createdAt, updatedAt sql.NullString + if err = rows.Scan(&id, &userID, ¤tID, &position, &changedBy, &items, &createdAt, &updatedAt); err != nil { + return err + } + index := 0 + if currentID != "" && items != "" { + parts := strings.Split(items, ",") + for i, p := range parts { + if p == currentID { + index = i + break + } + } + } + _, err = stmt.Exec(id, userID, index, position, changedBy, items, createdAt, updatedAt) + if err != nil { + return err + } + } + if err = rows.Err(); err != nil { + return err + } + + if _, err = tx.ExecContext(ctx, `drop table playqueue;`); err != nil { + return err + } + _, err = tx.ExecContext(ctx, `alter table playqueue_dg_tmp rename to playqueue;`) + return err +} + +func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go new file mode 100644 index 000000000..e82a0749f --- /dev/null +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddFolderHash, downAddFolderHash) +} + +func upAddFolderHash(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `alter table folder add column hash varchar default '' not null;`) + return err +} + +func downAddFolderHash(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20250701010102_add_annotation_user_foreign_key.sql b/db/migrations/20250701010102_add_annotation_user_foreign_key.sql new file mode 100644 index 000000000..114de2a88 --- /dev/null +++ b/db/migrations/20250701010102_add_annotation_user_foreign_key.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS annotation_tmp +( + user_id varchar(255) not null + REFERENCES user(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + item_id varchar(255) default '' not null, + item_type varchar(255) default '' not null, + play_count integer default 0, + play_date datetime, + rating integer default 0, + starred bool default FALSE not null, + starred_at datetime, + unique (user_id, item_id, item_type) +); + + +INSERT INTO annotation_tmp( + user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at +) +SELECT user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at +FROM annotation +WHERE user_id IN ( + SELECT id FROM user +); + +DROP TABLE annotation; +ALTER TABLE annotation_tmp RENAME TO annotation; + +CREATE INDEX annotation_play_count + on annotation (play_count); +CREATE INDEX annotation_play_date + on annotation (play_date); +CREATE INDEX annotation_rating + on annotation (rating); +CREATE INDEX annotation_starred + on annotation (starred); +CREATE INDEX annotation_starred_at + on annotation (starred_at); + +-- +goose StatementEnd + +-- +goose Down + diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go new file mode 100644 index 000000000..8025229cc --- /dev/null +++ b/db/migrations/20250701010103_add_library_stats.go @@ -0,0 +1,48 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddLibraryStats, downAddLibraryStats) +} + +func upAddLibraryStats(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +alter table library add column total_songs integer default 0 not null; +alter table library add column total_albums integer default 0 not null; +alter table library add column total_artists integer default 0 not null; +alter table library add column total_folders integer default 0 not null; + alter table library add column total_files integer default 0 not null; + alter table library add column total_missing_files integer default 0 not null; + alter table library add column total_size integer default 0 not null; +update library set + total_songs = ( + select count(*) from media_file where library_id = library.id and missing = 0 + ), + total_albums = (select count(*) from album where library_id = library.id and missing = 0), + total_artists = ( + select count(*) from library_artist la + join artist a on la.artist_id = a.id + where la.library_id = library.id and a.missing = 0 + ), + total_folders = (select count(*) from folder where library_id = library.id and missing = 0 and num_audio_files > 0), + total_files = ( + select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) + from folder where library_id = library.id and missing = 0 + ), + total_missing_files = ( + select count(*) from media_file where library_id = library.id and missing = 1 + ), + total_size = (select ifnull(sum(size),0) from album where library_id = library.id and missing = 0); +`) + return err +} + +func downAddLibraryStats(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go new file mode 100644 index 000000000..163608d32 --- /dev/null +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -0,0 +1,49 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upMakeReplaygainFieldsNullable, downMakeReplaygainFieldsNullable) +} + +func upMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +ALTER TABLE media_file ADD COLUMN rg_album_gain_new real; +ALTER TABLE media_file ADD COLUMN rg_album_peak_new real; +ALTER TABLE media_file ADD COLUMN rg_track_gain_new real; +ALTER TABLE media_file ADD COLUMN rg_track_peak_new real; + +UPDATE media_file SET + rg_album_gain_new = rg_album_gain, + rg_album_peak_new = rg_album_peak, + rg_track_gain_new = rg_track_gain, + rg_track_peak_new = rg_track_peak; + +ALTER TABLE media_file DROP COLUMN rg_album_gain; +ALTER TABLE media_file DROP COLUMN rg_album_peak; +ALTER TABLE media_file DROP COLUMN rg_track_gain; +ALTER TABLE media_file DROP COLUMN rg_track_peak; + +ALTER TABLE media_file RENAME COLUMN rg_album_gain_new TO rg_album_gain; +ALTER TABLE media_file RENAME COLUMN rg_album_peak_new TO rg_album_peak; +ALTER TABLE media_file RENAME COLUMN rg_track_gain_new TO rg_track_gain; +ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; + `) + + if err != nil { + return err + } + + notice(tx, "Fetching replaygain fields properly will require a full scan") + return nil +} + +func downMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/db/migrations/20250701010105_remove_dangling_items.sql b/db/migrations/20250701010105_remove_dangling_items.sql new file mode 100644 index 000000000..aede49b6e --- /dev/null +++ b/db/migrations/20250701010105_remove_dangling_items.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- +goose StatementBegin +update media_file set missing = 1 where folder_id = ''; +update album set missing = 1 where folder_ids = '[]'; +-- +goose StatementEnd + +-- +goose Down diff --git a/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql new file mode 100644 index 000000000..1cd67dc32 --- /dev/null +++ b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql @@ -0,0 +1,65 @@ +-- +goose Up +-- +goose StatementBegin +WITH artist_role_counters AS ( + SELECT jt.atom AS artist_id, + substr( + replace(jt.path, '$.', ''), + 1, + CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0 + THEN instr(replace(jt.path, '$.', ''), '[') - 1 + ELSE length(replace(jt.path, '$.', '')) + END + ) AS role, + count(DISTINCT mf.album_id) AS album_count, + count(mf.id) AS count, + sum(mf.size) AS size + FROM media_file mf + JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL + GROUP BY jt.atom, role +), +artist_total_counters AS ( + SELECT mfa.artist_id, + 'total' AS role, + count(DISTINCT mf.album_id) AS album_count, + count(DISTINCT mf.id) AS count, + sum(mf.size) AS size + FROM media_file_artists mfa + JOIN media_file mf ON mfa.media_file_id = mf.id + GROUP BY mfa.artist_id +), +artist_participant_counter AS ( + SELECT mfa.artist_id, + 'maincredit' AS role, + count(DISTINCT mf.album_id) AS album_count, + count(DISTINCT mf.id) AS count, + sum(mf.size) AS size + FROM media_file_artists mfa + JOIN media_file mf ON mfa.media_file_id = mf.id + AND mfa.role IN ('albumartist', 'artist') + GROUP BY mfa.artist_id +), +combined_counters AS ( + SELECT artist_id, role, album_count, count, size FROM artist_role_counters + UNION + SELECT artist_id, role, album_count, count, size FROM artist_total_counters + UNION + SELECT artist_id, role, album_count, count, size FROM artist_participant_counter +), +artist_counters AS ( + SELECT artist_id AS id, + json_group_object( + replace(role, '"', ''), + json_object('a', album_count, 'm', count, 's', size) + ) AS counters + FROM combined_counters + GROUP BY artist_id +) +UPDATE artist +SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), + updated_at = datetime(current_timestamp, 'localtime') +WHERE artist.id <> ''; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +-- +goose StatementEnd diff --git a/db/migrations/20250701010107_add_mbid_indexes.sql b/db/migrations/20250701010107_add_mbid_indexes.sql new file mode 100644 index 000000000..f8a5a444b --- /dev/null +++ b/db/migrations/20250701010107_add_mbid_indexes.sql @@ -0,0 +1,27 @@ +-- +goose Up +-- +goose StatementBegin + +-- Add indexes for MBID fields to improve lookup performance +-- Artists table +create index if not exists artist_mbz_artist_id + on artist (mbz_artist_id); + +-- Albums table +create index if not exists album_mbz_album_id + on album (mbz_album_id); + +-- Media files table +create index if not exists media_file_mbz_release_track_id + on media_file (mbz_release_track_id); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +-- Remove MBID indexes +drop index if exists artist_mbz_artist_id; +drop index if exists album_mbz_album_id; +drop index if exists media_file_mbz_release_track_id; + +-- +goose StatementEnd diff --git a/git/pre-commit b/git/pre-commit index 04f87994b..39ec8797f 100755 --- a/git/pre-commit +++ b/git/pre-commit @@ -12,7 +12,7 @@ gofmtcmd="go tool goimports" -gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$') +gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$' | grep -v '.pb.go$') [ -z "$gofiles" ] && exit 0 unformatted=$($gofmtcmd -l $gofiles) diff --git a/go.mod b/go.mod index d907aea0e..e8951570e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.24.2 +go 1.24.4 // Fork to fix https://github.com/navidrome/navidrome/pull/3254 replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d @@ -22,7 +22,7 @@ require ( github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/fatih/structs v1.1.0 - github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/cors v1.2.1 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.3.3 @@ -31,21 +31,24 @@ require ( github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 github.com/google/wire v0.6.0 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 - github.com/jellydator/ttlcache/v3 v3.3.0 + github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.2 + 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.4 + github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.27 + github.com/mattn/go-sqlite3 v1.14.28 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pocketbase/dbx v1.11.0 - github.com/pressly/goose/v3 v3.24.2 - github.com/prometheus/client_golang v1.21.1 + github.com/pressly/goose/v3 v3.24.3 + github.com/prometheus/client_golang v1.22.0 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 @@ -53,20 +56,24 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 + github.com/tetratelabs/wazero v1.9.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 go.uber.org/goleak v1.3.0 - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 - golang.org/x/image v0.26.0 - golang.org/x/net v0.38.0 - golang.org/x/sync v0.13.0 - golang.org/x/sys v0.32.0 - golang.org/x/text v0.24.0 - golang.org/x/time v0.11.0 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b + golang.org/x/image v0.28.0 + golang.org/x/net v0.41.0 + golang.org/x/sync v0.15.0 + golang.org/x/sys v0.33.0 + golang.org/x/text v0.26.0 + golang.org/x/time v0.12.0 + google.golang.org/protobuf v1.36.6 gopkg.in/yaml.v3 v3.0.1 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/reflex v0.3.1 // indirect @@ -75,56 +82,59 @@ require ( 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/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-logr/logr v1.4.2 // 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.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.17.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // 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/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/lestrrat-go/blackmagic v1.0.2 // indirect + github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ogier/pflag v0.0.1 // indirect + 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.1 // indirect github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.16.0 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/sosodev/duration v1.3.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/cast v1.9.2 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/tools v0.31.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/tools v0.34.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) tool ( + github.com/atombender/go-jsonschema github.com/cespare/reflex github.com/google/wire/cmd/wire github.com/onsi/ginkgo/v2/ginkgo diff --git a/go.sum b/go.sum index 864c9fd32..c6f96bec3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -6,6 +8,8 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= +github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -22,6 +26,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N 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/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= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -55,27 +60,29 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= -github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 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.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= +github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg= github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -85,8 +92,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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= +github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= 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= @@ -97,6 +104,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -104,8 +113,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 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.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= -github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= @@ -114,8 +123,10 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNU github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/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= @@ -130,30 +141,32 @@ github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= -github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= -github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= +github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4/qc= -github.com/lestrrat-go/jwx/v2 v2.1.4/go.mod h1:nWRbDFR1ALG2Z6GJbBXzfQaYyvn751KuuyySN2yR6is= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +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/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.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= -github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -167,6 +180,9 @@ github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU 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= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -174,16 +190,16 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU= -github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k= -github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= +github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= -github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 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= @@ -198,6 +214,8 @@ github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDj github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= +github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -209,12 +227,14 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= @@ -225,6 +245,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ 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.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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= @@ -234,6 +255,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= @@ -256,21 +279,21 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m 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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= -golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= 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.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 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= @@ -283,8 +306,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 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= @@ -292,8 +315,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -312,8 +335,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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= @@ -334,10 +357,10 @@ 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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -346,8 +369,8 @@ 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.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= @@ -363,11 +386,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= -modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y= +modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= -modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA= -modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= diff --git a/log/log.go b/log/log.go index 08a487fcd..20119ab46 100644 --- a/log/log.go +++ b/log/log.go @@ -203,6 +203,10 @@ func log(level Level, args ...interface{}) { logger.Log(logrus.Level(level), msg) } +func Writer() io.Writer { + return defaultLogger.Writer() +} + func shouldLog(requiredLevel Level, skip int) bool { if currentLevel >= requiredLevel { return true diff --git a/log/redactrus.go b/log/redactrus.go index d743e3f2d..6e17243e7 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -42,8 +42,9 @@ func (h *Hook) Fire(e *logrus.Entry) error { e.Data[k] = "[REDACTED]" continue } - - // Redact based on value matching in Data fields + if v == nil { + continue + } switch reflect.TypeOf(v).Kind() { case reflect.String: e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2") diff --git a/model/artist.go b/model/artist.go index 68836ff28..7f68f9787 100644 --- a/model/artist.go +++ b/model/artist.go @@ -82,7 +82,7 @@ type ArtistRepository interface { // The following methods are used exclusively by the scanner: RefreshPlayCounts() (int64, error) - RefreshStats() (int64, error) + RefreshStats(allArtists bool) (int64, error) AnnotatedRepository SearchableRepository[Artists] diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index e5a6efdff..fa92c5aca 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,6 +4,7 @@ package criteria import ( "encoding/json" "errors" + "fmt" "strings" "github.com/Masterminds/squirrel" @@ -24,13 +25,39 @@ func (c Criteria) OrderBy() string { if c.Sort == "" { c.Sort = "title" } - sortField := strings.ToLower(c.Sort) - f := fieldMap[sortField] - var mapped string - if f == nil { - log.Error("Invalid field in 'sort' field. Using 'title'", "sort", c.Sort) - mapped = fieldMap["title"].field - } else { + + order := strings.ToLower(strings.TrimSpace(c.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + order = "" + } + + parts := strings.Split(c.Sort, ",") + fields := make([]string, 0, len(parts)) + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + dir := "asc" + if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") { + if strings.HasPrefix(p, "-") { + dir = "desc" + } + p = strings.TrimSpace(p[1:]) + } + + sortField := strings.ToLower(p) + f := fieldMap[sortField] + if f == nil { + log.Error("Invalid field in 'sort' field", "sort", sortField) + continue + } + + var mapped string + if f.order != "" { mapped = f.order } else if f.isTag { @@ -40,15 +67,23 @@ func (c Criteria) OrderBy() string { } else { mapped = f.field } - } - if c.Order != "" { - if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") { - mapped = mapped + " " + c.Order - } else { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + if f.numeric { + mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) } + // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction. + // This ensures that the global order applies consistently across all fields. + if order == "desc" { + if dir == "asc" { + dir = "desc" + } else { + dir = "asc" + } + } + + fields = append(fields, mapped+" "+dir) } - return mapped + + return strings.Join(fields, ", ") } func (c Criteria) ToSql() (sql string, args []any, err error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 0c5777580..3792264a5 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -109,11 +109,42 @@ var _ = Describe("Criteria", func() { ) }) + It("casts numeric tags when sorting", func() { + AddTagNames([]string{"rate"}) + AddNumericTags([]string{"rate"}) + goObj.Sort = "rate" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"), + ) + }) + It("sorts by random", func() { newObj := goObj newObj.Sort = "random" gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) }) + + It("sorts by multiple fields", func() { + goObj.Sort = "title,-rating" + gomega.Expect(goObj.OrderBy()).To(gomega.Equal( + "media_file.title asc, COALESCE(annotation.rating, 0) desc", + )) + }) + + It("reverts order when order is desc", func() { + goObj.Sort = "-date,artist" + goObj.Order = "desc" + gomega.Expect(goObj.OrderBy()).To(gomega.Equal( + "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc", + )) + }) + + It("ignores invalid sort fields", func() { + goObj.Sort = "bogus,title" + gomega.Expect(goObj.OrderBy()).To(gomega.Equal( + "media_file.title asc", + )) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b6b852af5..fdcd3828b 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -10,43 +10,49 @@ import ( ) var fieldMap = map[string]*mappedField{ - "title": {field: "media_file.title"}, - "album": {field: "media_file.album"}, - "hascoverart": {field: "media_file.has_cover_art"}, - "tracknumber": {field: "media_file.track_number"}, - "discnumber": {field: "media_file.disc_number"}, - "year": {field: "media_file.year"}, - "date": {field: "media_file.date", alias: "recordingdate"}, - "originalyear": {field: "media_file.original_year"}, - "originaldate": {field: "media_file.original_date"}, - "releaseyear": {field: "media_file.release_year"}, - "releasedate": {field: "media_file.release_date"}, - "size": {field: "media_file.size"}, - "compilation": {field: "media_file.compilation"}, - "dateadded": {field: "media_file.created_at"}, - "datemodified": {field: "media_file.updated_at"}, - "discsubtitle": {field: "media_file.disc_subtitle"}, - "comment": {field: "media_file.comment"}, - "lyrics": {field: "media_file.lyrics"}, - "sorttitle": {field: "media_file.sort_title"}, - "sortalbum": {field: "media_file.sort_album_name"}, - "sortartist": {field: "media_file.sort_artist_name"}, - "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"}, - "albumcomment": {field: "media_file.mbz_album_comment"}, - "catalognumber": {field: "media_file.catalog_num"}, - "filepath": {field: "media_file.path"}, - "filetype": {field: "media_file.suffix"}, - "duration": {field: "media_file.duration"}, - "bitrate": {field: "media_file.bit_rate"}, - "bitdepth": {field: "media_file.bit_depth"}, - "bpm": {field: "media_file.bpm"}, - "channels": {field: "media_file.channels"}, - "loved": {field: "COALESCE(annotation.starred, false)"}, - "dateloved": {field: "annotation.starred_at"}, - "lastplayed": {field: "annotation.play_date"}, - "playcount": {field: "COALESCE(annotation.play_count, 0)"}, - "rating": {field: "COALESCE(annotation.rating, 0)"}, + "title": {field: "media_file.title"}, + "album": {field: "media_file.album"}, + "hascoverart": {field: "media_file.has_cover_art"}, + "tracknumber": {field: "media_file.track_number"}, + "discnumber": {field: "media_file.disc_number"}, + "year": {field: "media_file.year"}, + "date": {field: "media_file.date", alias: "recordingdate"}, + "originalyear": {field: "media_file.original_year"}, + "originaldate": {field: "media_file.original_date"}, + "releaseyear": {field: "media_file.release_year"}, + "releasedate": {field: "media_file.release_date"}, + "size": {field: "media_file.size"}, + "compilation": {field: "media_file.compilation"}, + "dateadded": {field: "media_file.created_at"}, + "datemodified": {field: "media_file.updated_at"}, + "discsubtitle": {field: "media_file.disc_subtitle"}, + "comment": {field: "media_file.comment"}, + "lyrics": {field: "media_file.lyrics"}, + "sorttitle": {field: "media_file.sort_title"}, + "sortalbum": {field: "media_file.sort_album_name"}, + "sortartist": {field: "media_file.sort_artist_name"}, + "sortalbumartist": {field: "media_file.sort_album_artist_name"}, + "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"}, + "albumcomment": {field: "media_file.mbz_album_comment"}, + "catalognumber": {field: "media_file.catalog_num"}, + "filepath": {field: "media_file.path"}, + "filetype": {field: "media_file.suffix"}, + "duration": {field: "media_file.duration"}, + "bitrate": {field: "media_file.bit_rate"}, + "bitdepth": {field: "media_file.bit_depth"}, + "bpm": {field: "media_file.bpm"}, + "channels": {field: "media_file.channels"}, + "loved": {field: "COALESCE(annotation.starred, false)"}, + "dateloved": {field: "annotation.starred_at"}, + "lastplayed": {field: "annotation.play_date"}, + "playcount": {field: "COALESCE(annotation.play_count, 0)"}, + "rating": {field: "COALESCE(annotation.rating, 0)"}, + "mbz_album_id": {field: "media_file.mbz_album_id"}, + "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, + "mbz_artist_id": {field: "media_file.mbz_artist_id"}, + "mbz_recording_id": {field: "media_file.mbz_recording_id"}, + "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, + "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, // special fields "random": {field: "", order: "random()"}, // pseudo-field for random sorting @@ -54,11 +60,12 @@ var fieldMap = map[string]*mappedField{ } type mappedField struct { - field string - order string - isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) - isTag bool // true if the field is a tag imported from the file metadata - alias string // name from `mappings.yml` that may differ from the name used in the smart playlist + field string + order string + isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) + isTag bool // true if the field is a tag imported from the file metadata + alias string // name from `mappings.yml` that may differ from the name used in the smart playlist + numeric bool // true if the field/tag should be treated as numeric } func mapFields(expr map[string]any) map[string]any { @@ -145,6 +152,12 @@ type tagCond struct { func (e tagCond) ToSql() (string, []any, error) { cond, args, err := e.cond.ToSql() + + // Check if this tag is marked as numeric in the fieldMap + if fm, ok := fieldMap[e.tag]; ok && fm.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", e.tag, cond) if e.not { @@ -205,3 +218,16 @@ func AddTagNames(tagNames []string) { } } } + +// AddNumericTags marks the given tag names as numeric so they can be cast +// when used in comparisons or sorting. +func AddNumericTags(tagNames []string) { + for _, name := range tagNames { + name := strings.ToLower(name) + if fm, ok := fieldMap[name]; ok { + fm.numeric = true + } else { + fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true} + } + } +} diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index e6082de44..95f9fc5f4 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -13,6 +13,7 @@ import ( var _ = BeforeSuite(func() { AddRoles([]string{"artist", "composer"}) AddTagNames([]string{"genre"}) + AddNumericTags([]string{"rate"}) }) var _ = Describe("Operators", func() { @@ -68,6 +69,15 @@ var _ = Describe("Operators", func() { Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), ) + // TODO Validate operators that are not valid for each field type. + XDescribeTable("ToSQL - Invalid Operators", + func(op Expression, expectedError string) { + _, _, err := op.ToSql() + gomega.Expect(err).To(gomega.MatchError(expectedError)) + }, + Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"), + ) + Describe("Custom Tags", func() { It("generates valid SQL", func() { AddTagNames([]string{"mood"}) @@ -77,6 +87,14 @@ var _ = Describe("Operators", func() { gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)")) gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) }) + It("casts numeric comparisons", func() { + AddNumericTags([]string{"rate"}) + op := Lt{"rate": 6} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) + gomega.Expect(args).To(gomega.HaveExactElements(6)) + }) It("skips unknown tag names", func() { op := EndsWith{"unknown": "value"} sql, args, _ := op.ToSql() diff --git a/model/folder.go b/model/folder.go index 3d14e7c53..12e0d711e 100644 --- a/model/folder.go +++ b/model/folder.go @@ -25,6 +25,7 @@ type Folder struct { NumPlaylists int `structs:"num_playlists"` ImageFiles []string `structs:"image_files"` ImagesUpdatedAt time.Time `structs:"images_updated_at"` + Hash string `structs:"hash"` Missing bool `structs:"missing"` UpdateAt time.Time `structs:"updated_at"` CreatedAt time.Time `structs:"created_at"` @@ -74,12 +75,17 @@ func NewFolder(lib Library, folderPath string) *Folder { type FolderCursor iter.Seq2[Folder, error] +type FolderUpdateInfo struct { + UpdatedAt time.Time + Hash string +} + type FolderRepository interface { Get(id string) (*Folder, error) GetByPath(lib Library, path string) (*Folder, error) GetAll(...QueryOptions) ([]Folder, error) CountAll(...QueryOptions) (int64, error) - GetLastUpdates(lib Library) (map[string]time.Time, error) + GetLastUpdates(lib Library) (map[string]FolderUpdateInfo, error) Put(*Folder) error MarkMissing(missing bool, ids ...string) error GetTouchedWithPlaylists() (FolderCursor, error) diff --git a/model/library.go b/model/library.go index a29f1c1d6..fda22f19f 100644 --- a/model/library.go +++ b/model/library.go @@ -14,6 +14,14 @@ type Library struct { FullScanInProgress bool UpdatedAt time.Time CreatedAt time.Time + + TotalSongs int + TotalAlbums int + TotalArtists int + TotalFolders int + TotalFiles int + TotalMissingFiles int + TotalSize int64 } type Libraries []Library @@ -32,4 +40,5 @@ type LibraryRepository interface { ScanBegin(id int, fullScan bool) error ScanEnd(id int) error ScanInProgress() (bool, error) + RefreshStats(id int) error } diff --git a/model/mediafile.go b/model/mediafile.go index 354c5ea47..d29a2a509 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -9,6 +9,7 @@ import ( "mime" "path/filepath" "slices" + "strings" "time" "github.com/gohugoio/hashstructure" @@ -35,53 +36,53 @@ type MediaFile struct { Artist string `structs:"artist" json:"artist"` AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead // AlbumArtist is the display name used for the album artist. - AlbumArtist string `structs:"album_artist" json:"albumArtist"` - AlbumID string `structs:"album_id" json:"albumId"` - HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` - TrackNumber int `structs:"track_number" json:"trackNumber"` - DiscNumber int `structs:"disc_number" json:"discNumber"` - DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` - Year int `structs:"year" json:"year"` - Date string `structs:"date" json:"date,omitempty"` - OriginalYear int `structs:"original_year" json:"originalYear"` - OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` - ReleaseYear int `structs:"release_year" json:"releaseYear"` - ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` - Size int64 `structs:"size" json:"size"` - Suffix string `structs:"suffix" json:"suffix"` - Duration float32 `structs:"duration" json:"duration"` - BitRate int `structs:"bit_rate" json:"bitRate"` - SampleRate int `structs:"sample_rate" json:"sampleRate"` - BitDepth int `structs:"bit_depth" json:"bitDepth"` - Channels int `structs:"channels" json:"channels"` - Genre string `structs:"genre" json:"genre"` - Genres Genres `structs:"-" json:"genres,omitempty"` - SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` - SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` - SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead - SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead - OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` - OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` - OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead - OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead - Compilation bool `structs:"compilation" json:"compilation"` - Comment string `structs:"comment" json:"comment,omitempty"` - Lyrics string `structs:"lyrics" json:"lyrics"` - BPM int `structs:"bpm" json:"bpm,omitempty"` - ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` - CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` - MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` - MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"` - MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` - MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` - MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead - MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead - MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` - RGAlbumGain float64 `structs:"rg_album_gain" json:"rgAlbumGain"` - RGAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` - RGTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain"` - RGTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak"` + AlbumArtist string `structs:"album_artist" json:"albumArtist"` + AlbumID string `structs:"album_id" json:"albumId"` + HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` + TrackNumber int `structs:"track_number" json:"trackNumber"` + DiscNumber int `structs:"disc_number" json:"discNumber"` + DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` + Year int `structs:"year" json:"year"` + Date string `structs:"date" json:"date,omitempty"` + OriginalYear int `structs:"original_year" json:"originalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseYear int `structs:"release_year" json:"releaseYear"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` + Size int64 `structs:"size" json:"size"` + Suffix string `structs:"suffix" json:"suffix"` + Duration float32 `structs:"duration" json:"duration"` + BitRate int `structs:"bit_rate" json:"bitRate"` + SampleRate int `structs:"sample_rate" json:"sampleRate"` + BitDepth int `structs:"bit_depth" json:"bitDepth"` + Channels int `structs:"channels" json:"channels"` + Genre string `structs:"genre" json:"genre"` + Genres Genres `structs:"-" json:"genres,omitempty"` + SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` + SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` + SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead + SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead + OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` + OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` + OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead + OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead + Compilation bool `structs:"compilation" json:"compilation"` + Comment string `structs:"comment" json:"comment,omitempty"` + Lyrics string `structs:"lyrics" json:"lyrics"` + BPM int `structs:"bpm" json:"bpm,omitempty"` + ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` + MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` + MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"` + MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` + MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` + MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead + MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead + MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` + RGTrackGain *float64 `structs:"rg_track_gain" json:"rgTrackGain"` + RGTrackPeak *float64 `structs:"rg_track_peak" json:"rgTrackPeak"` Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags from the original file Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this track @@ -330,6 +331,23 @@ func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int return currentPath, currentDisc } +// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in +// https://docs.fileformat.com/audio/m3u/#extended-m3u +func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string { + buf := strings.Builder{} + buf.WriteString("#EXTM3U\n") + buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title)) + for _, t := range mfs { + buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) + if absolutePaths { + buf.WriteString(t.AbsolutePath() + "\n") + } else { + buf.WriteString(t.Path + "\n") + } + } + return buf.String() +} + type MediaFileCursor iter.Seq2[MediaFile, error] type MediaFileRepository interface { @@ -342,6 +360,7 @@ type MediaFileRepository interface { GetCursor(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error DeleteMissing(ids []string) error + DeleteAllMissing() (int64, error) FindByPaths(paths []string) (MediaFiles, error) // The following methods are used exclusively by the scanner: diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 7f583cf75..635a61d30 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -402,6 +402,72 @@ var _ = Describe("MediaFiles", func() { }) }) }) + + Describe("ToM3U8", func() { + It("returns header only for empty MediaFiles", func() { + mfs = MediaFiles{} + result := mfs.ToM3U8("My Playlist", false) + Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n")) + }) + + DescribeTable("duration formatting", + func(duration float32, expected string) { + mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}} + result := mfs.ToM3U8("Test", false) + Expect(result).To(ContainSubstring(expected)) + }, + Entry("zero duration", float32(0.0), "#EXTINF:0,"), + Entry("whole number", float32(120.0), "#EXTINF:120,"), + Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"), + Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"), + ) + + Context("multiple tracks", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"}, + {Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"}, + {Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"}, + } + }) + + DescribeTable("generates correct output", + func(absolutePaths bool, expectedContent string) { + result := mfs.ToM3U8("Multi Track", absolutePaths) + Expect(result).To(Equal(expectedContent)) + }, + Entry("relative paths", + false, + "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n", + ), + Entry("absolute paths", + true, + "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n", + ), + Entry("special characters", + false, + "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n", + ), + ) + }) + + Context("path variations", func() { + It("handles different path structures", func() { + mfs = MediaFiles{ + {Title: "Root", Artist: "Artist", Duration: 60, Path: "song.mp3", LibraryPath: "/lib"}, + {Title: "Nested", Artist: "Artist", Duration: 60, Path: "deep/nested/song.mp3", LibraryPath: "/lib"}, + } + + relativeResult := mfs.ToM3U8("Test", false) + Expect(relativeResult).To(ContainSubstring("song.mp3\n")) + Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n")) + + absoluteResult := mfs.ToM3U8("Test", true) + Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n")) + Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n")) + }) + }) + }) }) var _ = Describe("MediaFile", func() { diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index b4857df85..591b618a3 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -53,9 +53,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.MbzAlbumType = md.String(model.TagReleaseType) // ReplayGain - mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1) + mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak) mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain) - mf.RGTrackPeak = md.Float(model.TagReplayGainTrackPeak, 1) + mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak) mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain) // General properties @@ -108,23 +108,24 @@ func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string { return getPID(mf, md, pidConf) } -func (md Metadata) mapGain(rg, r128 model.TagName) float64 { +func (md Metadata) mapGain(rg, r128 model.TagName) *float64 { v := md.Gain(rg) - if v != 0 { + if v != nil { return v } r128value := md.String(r128) if r128value != "" { var v, err = strconv.Atoi(r128value) if err != nil { - return 0 + return nil } // Convert Q7.8 to float - var value = float64(v) / 256.0 + value := float64(v) / 256.0 // Adding 5 dB to normalize with ReplayGain level - return value + 5 + value += 5 + return &value } - return 0 + return nil } func (md Metadata) mapLyrics() string { diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index 5317a4bcf..71cb9c1f2 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -564,6 +564,58 @@ var _ = Describe("Participants", func() { )) }) }) + + When("MUSICBRAINZ_PERFORMERID tag is set", func() { + matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher { + return MatchFields(IgnoreExtras, Fields{ + "Artist": MatchFields(IgnoreExtras, Fields{ + "Name": Equal(name), + "OrderArtistName": Equal(orderName), + "MbzArtistID": Equal(mbid), + }), + "SubRole": Equal(subRole), + }) + } + + It("should map MBIDs to the correct performer", func() { + mf = toMediaFile(model.RawTags{ + "PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"}, + "PERFORMER:BASS": {"Nathan East"}, + "MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"}, + "MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3))) + + p := participants[model.RolePerformer] + Expect(p).To(ContainElements( + matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"), + matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"), + matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"), + )) + }) + + It("should handle mismatched performer names and MBIDs for sub-roles", func() { + mf = toMediaFile(model.RawTags{ + "PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"}, + "MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs + "PERFORMER:DRUMS": {"Drummer X"}, + "MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer + + p := participants[model.RolePerformer] + Expect(p).To(ContainElements( + matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"), + matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"), + matchPerformer("Singer C", "singer c", "Vocals", ""), + matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"), + )) + }) + }) }) Describe("Other tags", func() { @@ -592,7 +644,6 @@ var _ = Describe("Participants", func() { Entry("REMIXER", model.RoleRemixer, "REMIXER"), Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"), Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"), - // TODO PERFORMER ) }) diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 471c2434c..aea4238a4 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -103,9 +103,11 @@ func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(k func (md Metadata) Float(key model.TagName, def ...float64) float64 { return float(md.first(key), def...) } -func (md Metadata) Gain(key model.TagName) float64 { +func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) } + +func (md Metadata) Gain(key model.TagName) *float64 { v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1)) - return float(v) + return nullableFloat(v) } func (md Metadata) Pairs(key model.TagName) []Pair { values := md.tags[key] @@ -119,14 +121,22 @@ func (md Metadata) first(key model.TagName) string { } func float(value string, def ...float64) float64 { + v := nullableFloat(value) + if v != nil { + return *v + } + if len(def) > 0 { + return def[0] + } + return 0 +} + +func nullableFloat(value string) *float64 { v, err := strconv.ParseFloat(value, 64) if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) { - if len(def) > 0 { - return def[0] - } - return 0 + return nil } - return v + return &v } // Used for tracks and discs diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index d7473afa7..82afd8657 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -257,38 +258,39 @@ var _ = Describe("Metadata", func() { } DescribeTable("Gain", - func(tagValue string, expected float64) { + func(tagValue string, expected *float64) { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", 0.0), - Entry("1.2dB", "1.2dB", 1.2), - Entry("Infinity", "Infinity", 0.0), - Entry("Invalid value", "INVALID VALUE", 0.0), - Entry("NaN", "NaN", 0.0), + Entry("0", "0", gg.P(0.0)), + Entry("1.2dB", "1.2dB", gg.P(1.2)), + Entry("Infinity", "Infinity", nil), + Entry("Invalid value", "INVALID VALUE", nil), + Entry("NaN", "NaN", nil), ) DescribeTable("Peak", - func(tagValue string, expected float64) { + func(tagValue string, expected *float64) { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", 0.0), - Entry("0.5", "0.5", 0.5), - Entry("Invalid dB suffix", "0.7dB", 1.0), - Entry("Infinity", "Infinity", 1.0), - Entry("Invalid value", "INVALID VALUE", 1.0), - Entry("NaN", "NaN", 1.0), + Entry("0", "0", gg.P(0.0)), + Entry("1.0", "1.0", gg.P(1.0)), + Entry("0.5", "0.5", gg.P(0.5)), + Entry("Invalid dB suffix", "0.7dB", nil), + Entry("Infinity", "Infinity", nil), + Entry("Invalid value", "INVALID VALUE", nil), + Entry("NaN", "NaN", nil), ) DescribeTable("getR128GainValue", - func(tagValue string, expected float64) { + func(tagValue string, expected *float64) { mf := createMF("r128_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", 5.0), - Entry("-3776", "-3776", -9.75), - Entry("Infinity", "Infinity", 0.0), - Entry("Invalid value", "INVALID VALUE", 0.0), + Entry("0", "0", gg.P(5.0)), + Entry("-3776", "-3776", gg.P(-9.75)), + Entry("Infinity", "Infinity", nil), + Entry("Invalid value", "INVALID VALUE", nil), ) }) diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go index a71749e81..0a1451cfb 100644 --- a/model/metadata/persistent_ids.go +++ b/model/metadata/persistent_ids.go @@ -24,6 +24,7 @@ type hashFunc = func(...string) string func createGetPID(hash hashFunc) func(mf model.MediaFile, md Metadata, spec string) string { var getPID func(mf model.MediaFile, md Metadata, spec string) string getAttr := func(mf model.MediaFile, md Metadata, attr string) string { + attr = strings.TrimSpace(strings.ToLower(attr)) switch attr { case "albumid": return getPID(mf, md, conf.Server.PID.Album) diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index 6903abc05..d07b36331 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -61,6 +61,7 @@ var _ = Describe("getPID", func() { }) }) }) + Context("calculated attributes", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -114,4 +115,36 @@ var _ = Describe("getPID", func() { }) }) }) + + Context("edge cases", func() { + When("the spec has spaces between groups", func() { + It("should return the pid", func() { + spec := "albumartist| Album" + md.tags = map[model.TagName][]string{ + "album": {"album name"}, + } + Expect(getPID(mf, md, spec)).To(Equal("(album name)")) + }) + }) + When("the spec has spaces", func() { + It("should return the pid", func() { + spec := "albumartist, album" + md.tags = map[model.TagName][]string{ + "albumartist": {"Album Artist"}, + "album": {"album name"}, + } + Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) + }) + }) + When("the spec has mixed case fields", func() { + It("should return the pid", func() { + spec := "albumartist,Album" + md.tags = map[model.TagName][]string{ + "albumartist": {"Album Artist"}, + "album": {"album name"}, + } + Expect(getPID(mf, md, spec)).To(Equal("(Album Artist\\album name)")) + }) + }) + }) }) diff --git a/model/participants.go b/model/participants.go index 5f07bf42c..afbda10de 100644 --- a/model/participants.go +++ b/model/participants.go @@ -25,6 +25,8 @@ var ( RoleRemixer = Role{"remixer"} RoleDJMixer = Role{"djmixer"} RolePerformer = Role{"performer"} + // RoleMainCredit is a credit where the artist is an album artist or artist + RoleMainCredit = Role{"maincredit"} ) var AllRoles = map[string]Role{ @@ -41,6 +43,7 @@ var AllRoles = map[string]Role{ RoleRemixer.role: RoleRemixer, RoleDJMixer.role: RoleDJMixer, RolePerformer.role: RolePerformer, + RoleMainCredit.role: RoleMainCredit, } // Role represents the role of an artist in a track or album. diff --git a/model/playlist.go b/model/playlist.go index 521adfcd0..40b666ff9 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,10 +1,8 @@ package model import ( - "fmt" "slices" "strconv" - "strings" "time" "github.com/navidrome/navidrome/model/criteria" @@ -53,17 +51,9 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) { pls.Tracks = newTracks } -// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in -// https://docs.fileformat.com/audio/m3u/#extended-m3u +// ToM3U8 exports the playlist to the Extended M3U8 format func (pls *Playlist) ToM3U8() string { - buf := strings.Builder{} - buf.WriteString("#EXTM3U\n") - buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name)) - for _, t := range pls.Tracks { - buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) - buf.WriteString(t.AbsolutePath() + "\n") - } - return buf.String() + return pls.MediaFiles().ToM3U8(pls.Name, true) } func (pls *Playlist) AddTracks(mediaFileIds []string) { @@ -111,6 +101,7 @@ type PlaylistRepository interface { FindByPath(path string) (*Playlist, error) Delete(id string) error Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository + GetPlaylists(mediaFileId string) (Playlists, error) } type PlaylistTrack struct { diff --git a/model/playlists_test.go b/model/playlist_test.go similarity index 71% rename from model/playlists_test.go rename to model/playlist_test.go index 600e116cc..a54cecd53 100644 --- a/model/playlists_test.go +++ b/model/playlist_test.go @@ -13,13 +13,17 @@ var _ = Describe("Playlist", func() { pls = model.Playlist{Name: "Mellow sunset"} pls.Tracks = model.PlaylistTracks{ {MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About", - Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}}, + Duration: 377.84, + LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}}, {MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)", - Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}}, + Duration: 374.49, + LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}}, {MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side", - Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}}, + Duration: 253.1, + LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}}, {MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home", - Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}}, + Duration: 163.89, + LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}}, } }) It("generates the correct M3U format", func() { diff --git a/model/playqueue.go b/model/playqueue.go index 52ba173d3..03b562253 100644 --- a/model/playqueue.go +++ b/model/playqueue.go @@ -7,7 +7,7 @@ import ( type PlayQueue struct { ID string `structs:"id" json:"id"` UserID string `structs:"user_id" json:"userId"` - Current string `structs:"current" json:"current"` + Current int `structs:"current" json:"current"` Position int64 `structs:"position" json:"position"` ChangedBy string `structs:"changed_by" json:"changedBy"` Items MediaFiles `structs:"-" json:"items,omitempty"` @@ -18,6 +18,11 @@ type PlayQueue struct { type PlayQueues []PlayQueue type PlayQueueRepository interface { - Store(queue *PlayQueue) error + Store(queue *PlayQueue, colNames ...string) error + // Retrieve returns the playqueue without loading the full MediaFiles + // (Items only contain IDs) Retrieve(userId string) (*PlayQueue, error) + // RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded + RetrieveWithMediaFiles(userId string) (*PlayQueue, error) + Clear(userId string) error } diff --git a/model/request/request.go b/model/request/request.go index 5f2980340..8d7919298 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -17,6 +17,7 @@ const ( Transcoding = contextKey("transcoding") ClientUniqueId = contextKey("clientUniqueId") ReverseProxyIp = contextKey("reverseProxyIp") + InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins ) var allKeys = []contextKey{ @@ -28,6 +29,7 @@ var allKeys = []contextKey{ Transcoding, ClientUniqueId, ReverseProxyIp, + InternalAuth, } func WithUser(ctx context.Context, u model.User) context.Context { @@ -62,6 +64,10 @@ func WithReverseProxyIp(ctx context.Context, reverseProxyIp string) context.Cont return context.WithValue(ctx, ReverseProxyIp, reverseProxyIp) } +func WithInternalAuth(ctx context.Context, username string) context.Context { + return context.WithValue(ctx, InternalAuth, username) +} + func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok @@ -102,6 +108,15 @@ func ReverseProxyIpFrom(ctx context.Context) (string, bool) { return v, ok } +func InternalAuthFrom(ctx context.Context) (string, bool) { + if v := ctx.Value(InternalAuth); v != nil { + if username, ok := v.(string); ok { + return username, true + } + } + return "", false +} + func AddValues(ctx, requestCtx context.Context) context.Context { for _, key := range allKeys { if v := requestCtx.Value(key); v != nil { diff --git a/model/share.go b/model/share.go index 0f52f5323..acb5fb428 100644 --- a/model/share.go +++ b/model/share.go @@ -2,7 +2,6 @@ package model import ( "cmp" - "fmt" "strings" "time" @@ -50,17 +49,9 @@ func (s Share) CoverArtID() ArtworkID { type Shares []Share -// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in -// https://docs.fileformat.com/audio/m3u/#extended-m3u +// ToM3U8 exports the share to the Extended M3U8 format. func (s Share) ToM3U8() string { - buf := strings.Builder{} - buf.WriteString("#EXTM3U\n") - buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", cmp.Or(s.Description, s.ID))) - for _, t := range s.Tracks { - buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) - buf.WriteString(t.Path + "\n") - } - return buf.String() + return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false) } type ShareRepository interface { diff --git a/model/tag_mappings.go b/model/tag_mappings.go index d11b58fdc..d54f51f43 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -162,6 +162,17 @@ func tagNames() []string { return names } +func numericTagNames() []string { + mappings := TagMappings() + names := make([]string, 0) + for k, cfg := range mappings { + if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat { + names = append(names, string(k)) + } + } + return names +} + func loadTagMappings() { mappingsFile, err := resources.FS().Open("mappings.yaml") if err != nil { @@ -228,5 +239,6 @@ func init() { // used in smart playlists criteria.AddRoles(slices.Collect(maps.Keys(AllRoles))) criteria.AddTagNames(tagNames()) + criteria.AddNumericTags(numericTagNames()) }) } diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 3f238ee23..08bc80039 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -12,6 +12,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -112,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito var albumFilters = sync.OnceValue(func() map[string]filterFunc { filters := map[string]filterFunc{ "id": idFilter("album"), - "name": fullTextFilter("album"), + "name": fullTextFilter("album", "mbz_album_id", "mbz_release_group_id"), "compilation": booleanFilter, "artist_id": artistFilter, "year": yearFilter, @@ -347,11 +348,18 @@ func (r *albumRepository) purgeEmpty() error { func (r *albumRepository) Search(q string, offset int, size int, includeMissing bool) (model.Albums, error) { var res dbAlbums - err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name") - if err != nil { - return nil, err + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectAlbum(), q, []string{"mbz_album_id", "mbz_release_group_id"}, includeMissing, &res) + if err != nil { + return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) + } + } else { + err := r.doSearch(r.selectAlbum(), q, offset, size, includeMissing, &res, "name") + if err != nil { + return nil, fmt.Errorf("searching album by query %q: %w", q, err) + } } - return res.toModels(), err + return res.toModels(), nil } func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b6b1aba44..f5b892ba1 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -11,6 +11,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -113,7 +114,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi r.tableName = "artist" // To be used by the idFilter below r.registerModel(&model.Artist{}, map[string]filterFunc{ "id": idFilter(r.tableName), - "name": fullTextFilter(r.tableName), + "name": fullTextFilter(r.tableName, "mbz_artist_id"), "starred": booleanFilter, "role": roleFilter, "missing": booleanFilter, @@ -124,12 +125,22 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi "song_count": "stats->>'total'->>'m'", "album_count": "stats->>'total'->>'a'", "size": "stats->>'total'->>'s'", + + // Stats by credits that are currently available + "maincredit_song_count": "stats->>'maincredit'->>'m'", + "maincredit_album_count": "stats->>'maincredit'->>'a'", + "maincredit_size": "stats->>'maincredit'->>'a'", }) return r } func roleFilter(_ string, role any) Sqlizer { - return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil} + if role, ok := role.(string); ok { + if _, ok := model.AllRoles[role]; ok { + return NotEq{fmt.Sprintf("stats ->> '$.%v'", role): nil} + } + } + return Eq{"1": 2} } func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { @@ -207,9 +218,9 @@ func (r *artistRepository) GetIndex(includeMissing bool, roles ...model.Role) (m options := model.QueryOptions{Sort: "name"} if len(roles) > 0 { roleFilters := slice.Map(roles, func(r model.Role) Sqlizer { - return roleFilter("role", r) + return roleFilter("role", r.String()) }) - options.Filters = And(roleFilters) + options.Filters = Or(roleFilters) } if !includeMissing { if options.Filters == nil { @@ -244,24 +255,23 @@ func (r *artistRepository) purgeEmpty() error { return nil } -// markMissing sets the Missing flag based on album data. -func (r *artistRepository) markMissing() (int64, error) { +// markMissing marks artists as missing if all their albums are missing. +func (r *artistRepository) markMissing() error { q := Expr(` - update artist - set missing = not exists ( - select 1 from album_artists aa - join album a on aa.album_id = a.id - where aa.artist_id = artist.id and a.missing = false - ) +with artists_with_non_missing_albums as ( + select distinct aa.artist_id + from album_artists aa + join album a on aa.album_id = a.id + where a.missing = false +) +update artist +set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums)); `) - c, err := r.executeSQL(q) + _, err := r.executeSQL(q) if err != nil { - return 0, fmt.Errorf("marking missing artists: %w", err) + return fmt.Errorf("marking missing artists: %w", err) } - if c > 0 { - log.Debug(r.ctx, "Marked missing artists", "totalUpdated", c) - } - return c, nil + return nil } // RefreshPlayCounts updates the play count and last play date annotations for all artists, based @@ -288,25 +298,33 @@ on conflict (user_id, item_id, item_type) do update } // RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. -// It processes artists in batches to handle potentially large updates. -func (r *artistRepository) RefreshStats() (int64, error) { - touchedArtistsQuerySQL := ` - SELECT DISTINCT mfa.artist_id - FROM media_file_artists mfa - JOIN media_file mf ON mfa.media_file_id = mf.id - WHERE mf.updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1) - ` - +// When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. +func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) { var allTouchedArtistIDs []string - if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { - return 0, fmt.Errorf("fetching touched artist IDs: %w", err) + if allArtists { + // Refresh stats for all artists + allArtistsQuerySQL := `SELECT DISTINCT id FROM artist WHERE id <> ''` + if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { + return 0, fmt.Errorf("fetching all artist IDs: %w", err) + } + log.Debug(r.ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) + } else { + // Only refresh artists with updated timestamps + touchedArtistsQuerySQL := ` + SELECT DISTINCT id + FROM artist + WHERE updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1) + ` + if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { + return 0, fmt.Errorf("fetching touched artist IDs: %w", err) + } + log.Debug(r.ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) } if len(allTouchedArtistIDs) == 0 { log.Debug(r.ctx, "RefreshStats: No artists to update.") return 0, nil } - log.Debug(r.ctx, "RefreshStats: Found artists to update.", "count", len(allTouchedArtistIDs)) // Template for the batch update with placeholder markers that we'll replace batchUpdateStatsSQL := ` @@ -336,13 +354,27 @@ func (r *artistRepository) RefreshStats() (int64, error) { sum(mf.size) AS size FROM media_file_artists mfa JOIN media_file mf ON mfa.media_file_id = mf.id - WHERE mfa.artist_id IN (TOTAL_IDS_PLACEHOLDER) -- Will replace with actual placeholders + WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders + GROUP BY mfa.artist_id + ), + artist_participant_counter AS ( + SELECT mfa.artist_id, + 'maincredit' AS role, + count(DISTINCT mf.album_id) AS album_count, + count(DISTINCT mf.id) AS count, + sum(mf.size) AS size + FROM media_file_artists mfa + JOIN media_file mf ON mfa.media_file_id = mf.id + WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders + AND mfa.role IN ('albumartist', 'artist') GROUP BY mfa.artist_id ), combined_counters AS ( SELECT artist_id, role, album_count, count, size FROM artist_role_counters UNION SELECT artist_id, role, album_count, count, size FROM artist_total_counters + UNION + SELECT artist_id, role, album_count, count, size FROM artist_participant_counter ), artist_counters AS ( SELECT artist_id AS id, @@ -356,7 +388,7 @@ func (r *artistRepository) RefreshStats() (int64, error) { UPDATE artist SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), updated_at = datetime(current_timestamp, 'localtime') - WHERE artist.id IN (UPDATE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders + WHERE artist.id IN (ROLE_IDS_PLACEHOLDER) AND artist.id <> '';` // Will replace with actual placeholders var totalRowsAffected int64 = 0 const batchSize = 1000 @@ -375,21 +407,16 @@ func (r *artistRepository) RefreshStats() (int64, error) { inClause := strings.Join(placeholders, ",") // Replace the placeholder markers with actual SQL placeholders - batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 1) - batchSQL = strings.Replace(batchSQL, "TOTAL_IDS_PLACEHOLDER", inClause, 1) - batchSQL = strings.Replace(batchSQL, "UPDATE_IDS_PLACEHOLDER", inClause, 1) + batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4) - // Create a single parameter array with all IDs (repeated 3 times for each IN clause) - // We need to repeat each ID 3 times (once for each IN clause) - var args []interface{} - for _, id := range artistIDBatch { - args = append(args, id) // For ROLE_IDS_PLACEHOLDER - } - for _, id := range artistIDBatch { - args = append(args, id) // For TOTAL_IDS_PLACEHOLDER - } - for _, id := range artistIDBatch { - args = append(args, id) // For UPDATE_IDS_PLACEHOLDER + // Create a single parameter array with all IDs (repeated 4 times for each IN clause) + // We need to repeat each ID 4 times (once for each IN clause) + args := make([]any, 4*len(artistIDBatch)) + for idx, id := range artistIDBatch { + for i := range 4 { + startIdx := i * len(artistIDBatch) + args[startIdx+idx] = id + } } // Now use Expr with the expanded SQL and all parameters @@ -407,12 +434,19 @@ func (r *artistRepository) RefreshStats() (int64, error) { } func (r *artistRepository) Search(q string, offset int, size int, includeMissing bool) (model.Artists, error) { - var dba dbArtists - err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &dba, "json_extract(stats, '$.total.m') desc", "name") - if err != nil { - return nil, err + var res dbArtists + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectArtist(), q, []string{"mbz_artist_id"}, includeMissing, &res) + if err != nil { + return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) + } + } else { + err := r.doSearch(r.selectArtist(), q, offset, size, includeMissing, &res, "json_extract(stats, '$.total.m') desc", "name") + if err != nil { + return nil, fmt.Errorf("searching artist by query %q: %w", q, err) + } } - return dba.toModels(), nil + return res.toModels(), nil } func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 0c7018dc8..0dc0b087c 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -163,6 +163,67 @@ var _ = Describe("ArtistRepository", func() { Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) }) }) + + When("filtering by role", func() { + var raw *artistRepository + + BeforeEach(func() { + raw = repo.(*artistRepository) + // Add stats to artists using direct SQL since Put doesn't populate stats + composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}` + producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}` + + // Set Beatles as composer + _, err := raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", composerStats).Where(squirrel.Eq{"id": artistBeatles.ID})) + Expect(err).ToNot(HaveOccurred()) + + // Set Kraftwerk as producer + _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", producerStats).Where(squirrel.Eq{"id": artistKraftwerk.ID})) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up stats + _, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistBeatles.ID})) + _, _ = raw.executeSQL(squirrel.Update(raw.tableName).Set("stats", "{}").Where(squirrel.Eq{"id": artistKraftwerk.ID})) + }) + + It("returns only artists with the specified role", func() { + idx, err := repo.GetIndex(false, model.RoleComposer) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(1)) + Expect(idx[0].ID).To(Equal("B")) + Expect(idx[0].Artists).To(HaveLen(1)) + Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) + }) + + It("returns artists with any of the specified roles", func() { + idx, err := repo.GetIndex(false, model.RoleComposer, model.RoleProducer) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + + // Find Beatles and Kraftwerk in the results + var beatlesFound, kraftwerkFound bool + for _, index := range idx { + for _, artist := range index.Artists { + if artist.Name == artistBeatles.Name { + beatlesFound = true + } + if artist.Name == artistKraftwerk.Name { + kraftwerkFound = true + } + } + } + Expect(beatlesFound).To(BeTrue()) + Expect(kraftwerkFound).To(BeTrue()) + }) + + It("returns empty index when no artists have the specified role", func() { + idx, err := repo.GetIndex(false, model.RoleDirector) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) + }) + }) }) Describe("dbArtist mapping", func() { @@ -321,4 +382,91 @@ var _ = Describe("ArtistRepository", func() { }) }) }) + + Describe("roleFilter", func() { + It("filters out roles not present in the participants model", func() { + Expect(roleFilter("", "artist")).To(Equal(squirrel.NotEq{"stats ->> '$.artist'": nil})) + Expect(roleFilter("", "albumartist")).To(Equal(squirrel.NotEq{"stats ->> '$.albumartist'": nil})) + Expect(roleFilter("", "composer")).To(Equal(squirrel.NotEq{"stats ->> '$.composer'": nil})) + Expect(roleFilter("", "conductor")).To(Equal(squirrel.NotEq{"stats ->> '$.conductor'": nil})) + Expect(roleFilter("", "lyricist")).To(Equal(squirrel.NotEq{"stats ->> '$.lyricist'": nil})) + Expect(roleFilter("", "arranger")).To(Equal(squirrel.NotEq{"stats ->> '$.arranger'": nil})) + Expect(roleFilter("", "producer")).To(Equal(squirrel.NotEq{"stats ->> '$.producer'": nil})) + Expect(roleFilter("", "director")).To(Equal(squirrel.NotEq{"stats ->> '$.director'": nil})) + Expect(roleFilter("", "engineer")).To(Equal(squirrel.NotEq{"stats ->> '$.engineer'": nil})) + Expect(roleFilter("", "mixer")).To(Equal(squirrel.NotEq{"stats ->> '$.mixer'": nil})) + Expect(roleFilter("", "remixer")).To(Equal(squirrel.NotEq{"stats ->> '$.remixer'": nil})) + Expect(roleFilter("", "djmixer")).To(Equal(squirrel.NotEq{"stats ->> '$.djmixer'": nil})) + Expect(roleFilter("", "performer")).To(Equal(squirrel.NotEq{"stats ->> '$.performer'": nil})) + + Expect(roleFilter("", "wizard")).To(Equal(squirrel.Eq{"1": 2})) + Expect(roleFilter("", "songanddanceman")).To(Equal(squirrel.Eq{"1": 2})) + Expect(roleFilter("", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--")).To(Equal(squirrel.Eq{"1": 2})) + }) + }) + + Context("MBID Search", func() { + var artistWithMBID model.Artist + var raw *artistRepository + + BeforeEach(func() { + raw = repo.(*artistRepository) + // Create a test artist with MBID + artistWithMBID = model.Artist{ + ID: "test-mbid-artist", + Name: "Test MBID Artist", + MbzArtistID: "550e8400-e29b-41d4-a716-446655440010", // Valid UUID v4 + } + + // Insert the test artist into the database + err := repo.Put(&artistWithMBID) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up test data using direct SQL + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + }) + + It("finds artist by mbz_artist_id", func() { + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440010", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-artist")) + Expect(results[0].Name).To(Equal("Test MBID Artist")) + }) + + It("returns empty result when MBID is not found", func() { + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("handles includeMissing parameter for MBID search", func() { + // Create a missing artist with MBID + missingArtist := model.Artist{ + ID: "test-missing-mbid-artist", + Name: "Test Missing MBID Artist", + MbzArtistID: "550e8400-e29b-41d4-a716-446655440012", + Missing: true, + } + + err := repo.Put(&missingArtist) + Expect(err).ToNot(HaveOccurred()) + + // Should not find missing artist when includeMissing is false + results, err := repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Should find missing artist when includeMissing is true + results, err = repo.Search("550e8400-e29b-41d4-a716-446655440012", 0, 10, true) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-missing-mbid-artist")) + + // Clean up + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) + }) + }) }) diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go index a8b7884b7..02b272134 100644 --- a/persistence/folder_repository.go +++ b/persistence/folder_repository.go @@ -89,19 +89,20 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { return r.count(sq) } -func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]time.Time, error) { - sq := r.newSelect().Columns("id", "updated_at").Where(Eq{"library_id": lib.ID, "missing": false}) +func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) { + sq := r.newSelect().Columns("id", "updated_at", "hash").Where(Eq{"library_id": lib.ID, "missing": false}) var res []struct { ID string UpdatedAt time.Time + Hash string } err := r.queryAll(sq, &res) if err != nil { return nil, err } - m := make(map[string]time.Time, len(res)) + m := make(map[string]model.FolderUpdateInfo, len(res)) for _, f := range res { - m[f.ID] = f.UpdatedAt + m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash} } return m, nil } diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 5ec54b964..9c305e52b 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/run" "github.com/pocketbase/dbx" ) @@ -146,6 +147,58 @@ func (r *libraryRepository) ScanInProgress() (bool, error) { return count > 0, err } +func (r *libraryRepository) RefreshStats(id int) error { + var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } + var sizeRes struct{ Sum int64 } + + err := run.Parallel( + func() error { + return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) + }, + func() error { + return r.queryOne(Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) + }, + func() error { + return r.queryOne(Select("count(*) as count").From("library_artist la"). + Join("artist a on la.artist_id = a.id"). + Where(Eq{"la.library_id": id, "a.missing": false}), &artistsRes) + }, + func() error { + return r.queryOne(Select("count(*) as count").From("folder"). + Where(And{ + Eq{"library_id": id, "missing": false}, + Gt{"num_audio_files": 0}, + }), &foldersRes) + }, + func() error { + return r.queryOne(Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count"). + From("folder").Where(Eq{"library_id": id, "missing": false}), &filesRes) + }, + func() error { + return r.queryOne(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) + }, + func() error { + return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) + }, + )() + if err != nil { + return err + } + + sq := Update(r.tableName). + Set("total_songs", songsRes.Count). + Set("total_albums", albumsRes.Count). + Set("total_artists", artistsRes.Count). + Set("total_folders", foldersRes.Count). + Set("total_files", filesRes.Count). + Set("total_missing_files", missingRes.Count). + Set("total_size", sizeRes.Sum). + Set("updated_at", time.Now()). + Where(Eq{"id": id}) + _, err = r.executeSQL(sq) + return err +} + func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) { sq := r.newSelect(ops...).Columns("*") res := model.Libraries{} diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go new file mode 100644 index 000000000..280f254b5 --- /dev/null +++ b/persistence/library_repository_test.go @@ -0,0 +1,52 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("LibraryRepository", func() { + var repo model.LibraryRepository + var ctx context.Context + var conn *dbx.DB + + BeforeEach(func() { + ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) + conn = GetDBXBuilder() + repo = NewLibraryRepository(ctx, conn) + }) + + It("refreshes stats", func() { + libBefore, err := repo.Get(1) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.RefreshStats(1)).To(Succeed()) + libAfter, err := repo.Get(1) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.UpdatedAt).To(BeTemporally(">", libBefore.UpdatedAt)) + + var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } + var sizeRes struct{ Sum int64 } + + Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed()) + Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed()) + Expect(conn.NewQuery("select count(*) as count from library_artist la join artist a on la.artist_id = a.id where la.library_id = {:id} and a.missing = 0").Bind(dbx.Params{"id": 1}).One(&artistsRes)).To(Succeed()) + Expect(conn.NewQuery("select count(*) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&foldersRes)).To(Succeed()) + Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed()) + Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed()) + Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed()) + + Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count))) + Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count))) + Expect(libAfter.TotalArtists).To(Equal(int(artistsRes.Count))) + Expect(libAfter.TotalFolders).To(Equal(int(foldersRes.Count))) + Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count))) + Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count))) + Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum)) + }) +}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index e236b1b29..dd22b1413 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -9,6 +9,8 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -25,10 +27,10 @@ type dbMediaFile struct { Tags string `structs:"-" json:"-"` // These are necessary to map the correct names (rg_*) to the correct fields (RG*) // without using `db` struct tags in the model.MediaFile struct - RgAlbumGain float64 `structs:"-" json:"-"` - RgAlbumPeak float64 `structs:"-" json:"-"` - RgTrackGain float64 `structs:"-" json:"-"` - RgTrackPeak float64 `structs:"-" json:"-"` + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` + RgTrackGain *float64 `structs:"-" json:"-"` + RgTrackPeak *float64 `structs:"-" json:"-"` } func (m *dbMediaFile) PostScan() error { @@ -74,13 +76,14 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile r.tableName = "media_file" r.registerModel(&model.MediaFile{}, mediaFileFilter()) r.setSortMappings(map[string]string{ - "title": "order_title", - "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", - "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", - "random": "random", - "created_at": "media_file.created_at", - "starred_at": "starred, starred_at", + "title": "order_title", + "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", + "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "random": "random", + "created_at": "media_file.created_at", + "recently_added": mediaFileRecentlyAddedSort(), + "starred_at": "starred, starred_at", }) return r } @@ -88,7 +91,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { filters := map[string]filterFunc{ "id": idFilter("media_file"), - "title": fullTextFilter("media_file"), + "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": booleanFilter, "genre_id": tagIDFilter, "missing": booleanFilter, @@ -103,6 +106,13 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { return filters }) +func mediaFileRecentlyAddedSort() string { + if conf.Server.RecentlyAddedByModTime { + return "media_file.updated_at" + } + return "media_file.created_at" +} + func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { query := r.newSelect() query = r.withAnnotation(query, "media_file.id") @@ -192,6 +202,15 @@ func (r *mediaFileRepository) Delete(id string) error { return r.delete(Eq{"id": id}) } +func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { + user := loggedUser(r.ctx) + if !user.IsAdmin { + return 0, rest.ErrPermissionDenied + } + del := Delete(r.tableName).Where(Eq{"missing": true}) + return r.executeSQL(del) +} + func (r *mediaFileRepository) DeleteMissing(ids []string) error { user := loggedUser(r.ctx) if !user.IsAdmin { @@ -276,12 +295,19 @@ func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileC } func (r *mediaFileRepository) Search(q string, offset int, size int, includeMissing bool) (model.MediaFiles, error) { - results := dbMediaFiles{} - err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &results, "title") - if err != nil { - return nil, err + var res dbMediaFiles + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectMediaFile(), q, []string{"mbz_recording_id", "mbz_release_track_id"}, includeMissing, &res) + if err != nil { + return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) + } + } else { + err := r.doSearch(r.selectMediaFile(), q, offset, size, includeMissing, &res, "title") + if err != nil { + return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) + } } - return results.toModels(), err + return res.toModels(), nil } func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 41b48c0c6..b1153b317 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -4,12 +4,16 @@ import ( "context" "time" + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" ) var _ = Describe("MediaRepository", func() { @@ -34,7 +38,31 @@ var _ = Describe("MediaRepository", func() { }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll()).To(Equal(int64(4))) + Expect(mr.CountAll()).To(Equal(int64(6))) + }) + + It("returns songs ordered by lyrics with a specific title/artist", func() { + // attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items + results, err := mr.GetAll(model.QueryOptions{ + Sort: "lyrics, updated_at", + Order: "desc", + Filters: squirrel.And{ + squirrel.Eq{"title": "Antenna"}, + squirrel.Or{ + Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}), + Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}), + }, + }, + }) + + Expect(err).To(BeNil()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`)) + for _, item := range results[1:] { + Expect(item.Lyrics).To(Equal("[]")) + Expect(item.Title).To(Equal("Antenna")) + Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk")) + } }) It("checks existence of mediafiles in the DB", func() { @@ -44,14 +72,39 @@ var _ = Describe("MediaRepository", func() { It("delete tracks by id", func() { newID := id.NewRandom() - Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) - Expect(mr.Delete(newID)).To(BeNil()) + Expect(mr.Delete(newID)).To(Succeed()) _, err := mr.Get(newID) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("deletes all missing files", func() { + new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} + new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} + Expect(mr.Put(&new1)).To(Succeed()) + Expect(mr.Put(&new2)).To(Succeed()) + Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed()) + + adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) + adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder()) + + // Ensure the files are marked as missing and we have 2 of them + count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) + Expect(count).To(BeNumerically("==", 2)) + Expect(err).ToNot(HaveOccurred()) + + count, err = adminRepo.DeleteAllMissing() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + + _, err = mr.Get(new1.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = mr.Get(new2.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" @@ -105,4 +158,262 @@ var _ = Describe("MediaRepository", func() { Expect(mf.PlayCount).To(Equal(int64(1))) }) }) + + Context("Sort options", func() { + Context("recently_added sort", func() { + var testMediaFiles []model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + // Create test media files with specific timestamps + testMediaFiles = []model.MediaFile{ + { + ID: id.NewRandom(), + LibraryID: 1, + Title: "Old Song", + Path: "/test/old.mp3", + }, + { + ID: id.NewRandom(), + LibraryID: 1, + Title: "Middle Song", + Path: "/test/middle.mp3", + }, + { + ID: id.NewRandom(), + LibraryID: 1, + Title: "New Song", + Path: "/test/new.mp3", + }, + } + + // Insert test data first + for i := range testMediaFiles { + Expect(mr.Put(&testMediaFiles[i])).To(Succeed()) + } + + // Then manually update timestamps using direct SQL to bypass the repository logic + db := GetDBXBuilder() + + // Set specific timestamps for testing + oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + + // Update "Old Song": created long ago, updated recently + _, err := db.Update("media_file", + map[string]interface{}{ + "created_at": oldTime, + "updated_at": newTime, + }, + dbx.HashExp{"id": testMediaFiles[0].ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + // Update "Middle Song": created and updated at the same middle time + _, err = db.Update("media_file", + map[string]interface{}{ + "created_at": middleTime, + "updated_at": middleTime, + }, + dbx.HashExp{"id": testMediaFiles[1].ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + + // Update "New Song": created recently, updated long ago + _, err = db.Update("media_file", + map[string]interface{}{ + "created_at": newTime, + "updated_at": oldTime, + }, + dbx.HashExp{"id": testMediaFiles[2].ID}).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up test data + for _, mf := range testMediaFiles { + _ = mr.Delete(mf.ID) + } + }) + + When("RecentlyAddedByModTime is false", func() { + var testRepo model.MediaFileRepository + + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + // Create repository AFTER setting config + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + }) + + It("sorts by created_at", func() { + // Get results sorted by recently_added (should use created_at) + results, err := testRepo.GetAll(model.QueryOptions{ + Sort: "recently_added", + Order: "desc", + Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // Verify sorting by created_at (newest first in descending order) + Expect(results[0].Title).To(Equal("New Song")) // created 2022 + Expect(results[1].Title).To(Equal("Middle Song")) // created 2021 + Expect(results[2].Title).To(Equal("Old Song")) // created 2020 + }) + + It("sorts in ascending order when specified", func() { + // Get results sorted by recently_added in ascending order + results, err := testRepo.GetAll(model.QueryOptions{ + Sort: "recently_added", + Order: "asc", + Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // Verify sorting by created_at (oldest first) + Expect(results[0].Title).To(Equal("Old Song")) // created 2020 + Expect(results[1].Title).To(Equal("Middle Song")) // created 2021 + Expect(results[2].Title).To(Equal("New Song")) // created 2022 + }) + }) + + When("RecentlyAddedByModTime is true", func() { + var testRepo model.MediaFileRepository + + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = true + // Create repository AFTER setting config + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + testRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + }) + + It("sorts by updated_at", func() { + // Get results sorted by recently_added (should use updated_at) + results, err := testRepo.GetAll(model.QueryOptions{ + Sort: "recently_added", + Order: "desc", + Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // Verify sorting by updated_at (newest first in descending order) + Expect(results[0].Title).To(Equal("Old Song")) // updated 2022 + Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021 + Expect(results[2].Title).To(Equal("New Song")) // updated 2020 + }) + }) + + }) + }) + + Describe("Search", func() { + Context("text search", func() { + It("finds media files by title", func() { + results, err := mr.Search("Antenna", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 + for _, result := range results { + Expect(result.Title).To(Equal("Antenna")) + } + }) + + It("finds media files case insensitively", func() { + results, err := mr.Search("antenna", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + for _, result := range results { + Expect(result.Title).To(Equal("Antenna")) + } + }) + + It("returns empty result when no matches found", func() { + results, err := mr.Search("nonexistent", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) + + Context("MBID search", func() { + var mediaFileWithMBID model.MediaFile + var raw *mediaFileRepository + + BeforeEach(func() { + raw = mr.(*mediaFileRepository) + // Create a test media file with MBID + mediaFileWithMBID = model.MediaFile{ + ID: "test-mbid-mediafile", + Title: "Test MBID MediaFile", + MbzRecordingID: "550e8400-e29b-41d4-a716-446655440020", // Valid UUID v4 + MbzReleaseTrackID: "550e8400-e29b-41d4-a716-446655440021", // Valid UUID v4 + LibraryID: 1, + Path: "/test/path/test.mp3", + } + + // Insert the test media file into the database + err := mr.Put(&mediaFileWithMBID) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up test data using direct SQL + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) + }) + + It("finds media file by mbz_recording_id", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-mediafile")) + Expect(results[0].Title).To(Equal("Test MBID MediaFile")) + }) + + It("finds media file by mbz_release_track_id", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440021", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-mbid-mediafile")) + Expect(results[0].Title).To(Equal("Test MBID MediaFile")) + }) + + It("returns empty result when MBID is not found", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440099", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("handles includeMissing parameter for MBID search", func() { + // Create a missing media file with MBID + missingMediaFile := model.MediaFile{ + ID: "test-missing-mbid-mediafile", + Title: "Test Missing MBID MediaFile", + MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022", + LibraryID: 1, + Path: "/test/path/missing.mp3", + Missing: true, + } + + err := mr.Put(&missingMediaFile) + Expect(err).ToNot(HaveOccurred()) + + // Should not find missing media file when includeMissing is false + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, false) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Should find missing media file when includeMissing is true + results, err = mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10, true) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ID).To(Equal("test-missing-mbid-mediafile")) + + // Clean up + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) + }) + }) + }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 31e03db61..ac607f85f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -9,7 +9,7 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/chain" + "github.com/navidrome/navidrome/utils/run" "github.com/pocketbase/dbx" ) @@ -167,10 +167,10 @@ func (s *SQLStore) GC(ctx context.Context) error { } } - err := chain.RunSequentially( + err := run.Sequentially( trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }), trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }), - trace(ctx, "mark missing artists", func() error { _, err := s.Artist(ctx).(*artistRepository).markMissing(); return err }), + trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }), trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }), trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 43e4c292b..7edfeee1f 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -38,6 +39,9 @@ func mf(mf model.MediaFile) model.MediaFile { model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}}, }, } + if mf.Lyrics == "" { + mf.Lyrics = "[]" + } return mf } @@ -76,13 +80,24 @@ var ( songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Path: p("/kraft/radio/antenna.mp3"), - RGAlbumGain: 1.0, RGAlbumPeak: 2.0, RGTrackGain: 3.0, RGTrackPeak: 4.0, + RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), }) - testSongs = model.MediaFiles{ + songAntennaWithLyrics = mf(model.MediaFile{ + ID: "1005", + Title: "Antenna", + ArtistID: "2", + Artist: "Kraftwerk", + AlbumID: "103", + Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`, + }) + songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"}) + testSongs = model.MediaFiles{ songDayInALife, songComeTogether, songRadioactivity, songAntenna, + songAntennaWithLyrics, + songAntenna2, } ) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 743eca470..bdaaeeddb 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -197,6 +197,25 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli return playlists, err } +func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { + sel := r.selectPlaylist(model.QueryOptions{Sort: "name"}). + Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). + Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter()}) + var res []dbPlaylist + err := r.queryAll(sel, &res) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.Playlists{}, nil + } + return nil, err + } + playlists := make(model.Playlists, len(res)) + for i, p := range res { + playlists[i] = p.Playlist + } + return playlists, nil +} + func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 5a82964c9..b799d4912 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -112,6 +112,21 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetPlaylists", func() { + It("returns playlists for a track", func() { + pls, err := repo.GetPlaylists(songRadioactivity.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(pls).To(HaveLen(1)) + Expect(pls[0].ID).To(Equal(plsBest.ID)) + }) + + It("returns empty when none", func() { + pls, err := repo.GetPlaylists("9999") + Expect(err).ToNot(HaveOccurred()) + Expect(pls).To(HaveLen(0)) + }) + }) + Context("Smart Playlists", func() { var rules *criteria.Criteria BeforeEach(func() { diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index d33bd5113..80925aa88 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -99,10 +99,10 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) { "playlist_tracks.*", ). Join("media_file f on f.id = media_file_id"). - Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}}) + Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) var trk dbPlaylistTrack err := r.queryOne(sel, &trk) - return trk.PlaylistTrack.MediaFile, err + return trk.PlaylistTrack, err } func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index fe42dd7fc..74c80ee92 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -2,6 +2,7 @@ package persistence import ( "context" + "errors" "strings" "time" @@ -27,7 +28,7 @@ func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueue type playQueue struct { ID string `structs:"id"` UserID string `structs:"user_id"` - Current string `structs:"current"` + Current int `structs:"current"` Position int64 `structs:"position"` ChangedBy string `structs:"changed_by"` Items string `structs:"items"` @@ -35,22 +36,39 @@ type playQueue struct { UpdatedAt time.Time `structs:"updated_at"` } -func (r *playQueueRepository) Store(q *model.PlayQueue) error { +func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) error { u := loggedUser(r.ctx) - err := r.clearPlayQueue(q.UserID) - if err != nil { - log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err) + + // Always find existing playqueue for this user + existingQueue, err := r.Retrieve(q.UserID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Error(r.ctx, "Error retrieving existing playqueue", "user", u.UserName, err) return err } - if len(q.Items) == 0 { - return nil + + // Use existing ID if found, otherwise keep the provided ID (which may be empty for new records) + if !errors.Is(err, model.ErrNotFound) && existingQueue.ID != "" { + q.ID = existingQueue.ID } + + // When no specific columns are provided, we replace the whole queue + if len(colNames) == 0 { + err := r.clearPlayQueue(q.UserID) + if err != nil { + log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err) + return err + } + if len(q.Items) == 0 { + return nil + } + } + pq := r.fromModel(q) if pq.ID == "" { pq.CreatedAt = time.Now() } pq.UpdatedAt = time.Now() - _, err = r.put(pq.ID, pq) + _, err = r.put(pq.ID, pq, colNames...) if err != nil { log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err) return err @@ -58,12 +76,21 @@ func (r *playQueueRepository) Store(q *model.PlayQueue) error { return nil } +func (r *playQueueRepository) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) + var res playQueue + err := r.queryOne(sel, &res) + q := r.toModel(&res) + q.Items = r.loadTracks(q.Items) + return &q, err +} + func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) { sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue err := r.queryOne(sel, &res) - pls := r.toModel(&res) - return &pls, err + q := r.toModel(&res) + return &q, err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { @@ -100,7 +127,6 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { q.Items = append(q.Items, model.MediaFile{ID: t}) } } - q.Items = r.loadTracks(q.Items) return q } @@ -145,4 +171,8 @@ func (r *playQueueRepository) clearPlayQueue(userId string) error { return r.delete(Eq{"user_id": userId}) } +func (r *playQueueRepository) Clear(userId string) error { + return r.clearPlayQueue(userId) +} + var _ model.PlayQueueRepository = (*playQueueRepository)(nil) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go index a370e1162..2bcc88fd0 100644 --- a/persistence/playqueue_repository_test.go +++ b/persistence/playqueue_repository_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" @@ -18,38 +19,296 @@ var _ = Describe("PlayQueueRepository", func() { var ctx context.Context BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) ctx = log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) repo = NewPlayQueueRepository(ctx, GetDBXBuilder()) }) - Describe("PlayQueues", func() { + Describe("Store", func() { + It("stores a complete playqueue", func() { + expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(Succeed()) + + actual, err := repo.RetrieveWithMediaFiles("userid") + Expect(err).ToNot(HaveOccurred()) + AssertPlayQueue(expected, actual) + Expect(countPlayQueues(repo, "userid")).To(Equal(1)) + }) + + It("replaces existing playqueue when storing without column names", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether) + Expect(repo.Store(initial)).To(Succeed()) + + By("Storing replacement playqueue") + replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna) + Expect(repo.Store(replacement)).To(Succeed()) + + actual, err := repo.RetrieveWithMediaFiles("userid") + Expect(err).ToNot(HaveOccurred()) + AssertPlayQueue(replacement, actual) + Expect(countPlayQueues(repo, "userid")).To(Equal(1)) + }) + + It("clears playqueue when storing empty items", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether) + Expect(repo.Store(initial)).To(Succeed()) + + By("Storing empty playqueue") + empty := aPlayQueue("userid", 0, 0) + Expect(repo.Store(empty)).To(Succeed()) + + By("Verifying playqueue is cleared") + _, err := repo.Retrieve("userid") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("updates only current field when specified", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) + Expect(repo.Store(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + + By("Updating only current field") + update := &model.PlayQueue{ + ID: existing.ID, // Use existing ID for partial update + UserID: "userid", + Current: 1, + ChangedBy: "test-update", + } + Expect(repo.Store(update, "current")).To(Succeed()) + + By("Verifying only current was updated") + actual, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Current).To(Equal(1)) + Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged + Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged + }) + + It("updates only position field when specified", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 1, 100, songComeTogether, songDayInALife) + Expect(repo.Store(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + + By("Updating only position field") + update := &model.PlayQueue{ + ID: existing.ID, // Use existing ID for partial update + UserID: "userid", + Position: 500, + ChangedBy: "test-update", + } + Expect(repo.Store(update, "position")).To(Succeed()) + + By("Verifying only position was updated") + actual, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Position).To(Equal(int64(500))) + Expect(actual.Current).To(Equal(1)) // Should remain unchanged + Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged + }) + + It("updates multiple specified fields", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether) + Expect(repo.Store(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + + By("Updating current and position fields") + update := &model.PlayQueue{ + ID: existing.ID, // Use existing ID for partial update + UserID: "userid", + Current: 1, + Position: 300, + ChangedBy: "test-update", + } + Expect(repo.Store(update, "current", "position")).To(Succeed()) + + By("Verifying both fields were updated") + actual, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Current).To(Equal(1)) + Expect(actual.Position).To(Equal(int64(300))) + Expect(actual.Items).To(HaveLen(1)) // Should remain unchanged + }) + + It("preserves existing data when updating with empty items list and column names", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) + Expect(repo.Store(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + + By("Updating only position with empty items") + update := &model.PlayQueue{ + ID: existing.ID, // Use existing ID for partial update + UserID: "userid", + Position: 200, + ChangedBy: "test-update", + Items: []model.MediaFile{}, // Empty items + } + Expect(repo.Store(update, "position")).To(Succeed()) + + By("Verifying items are preserved") + actual, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Position).To(Equal(int64(200))) + Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged + }) + + It("ensures only one record per user by reusing existing record ID", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether) + Expect(repo.Store(initial)).To(Succeed()) + initialCount := countPlayQueues(repo, "userid") + Expect(initialCount).To(Equal(1)) + + By("Storing another playqueue with different ID but same user") + different := aPlayQueue("userid", 1, 200, songDayInALife) + different.ID = "different-id" // Force a different ID + Expect(repo.Store(different)).To(Succeed()) + + By("Verifying only one record exists for the user") + finalCount := countPlayQueues(repo, "userid") + Expect(finalCount).To(Equal(1)) + + By("Verifying the record was updated, not duplicated") + actual, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Current).To(Equal(1)) // Should be updated value + Expect(actual.Position).To(Equal(int64(200))) // Should be updated value + Expect(actual.Items).To(HaveLen(1)) // Should be new items + Expect(actual.Items[0].ID).To(Equal(songDayInALife.ID)) + }) + + It("ensures only one record per user even with partial updates", func() { + By("Storing initial playqueue") + initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) + Expect(repo.Store(initial)).To(Succeed()) + initialCount := countPlayQueues(repo, "userid") + Expect(initialCount).To(Equal(1)) + + By("Storing partial update with different ID but same user") + partialUpdate := &model.PlayQueue{ + ID: "completely-different-id", // Use a completely different ID + UserID: "userid", + Current: 1, + ChangedBy: "test-partial", + } + Expect(repo.Store(partialUpdate, "current")).To(Succeed()) + + By("Verifying only one record still exists for the user") + finalCount := countPlayQueues(repo, "userid") + Expect(finalCount).To(Equal(1)) + + By("Verifying the existing record was updated with new current value") + actual, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Current).To(Equal(1)) // Should be updated value + Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged + Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged + }) + }) + + Describe("Retrieve", func() { It("returns notfound error if there's no playqueue for the user", func() { _, err := repo.Retrieve("user999") Expect(err).To(MatchError(model.ErrNotFound)) }) - It("stores and retrieves the playqueue for the user", func() { + It("retrieves the playqueue with only track IDs (no full MediaFile data)", func() { By("Storing a playqueue for the user") - expected := aPlayQueue("userid", songDayInALife.ID, 123, songComeTogether, songDayInALife) + expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) Expect(repo.Store(expected)).To(Succeed()) actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) - AssertPlayQueue(expected, actual) + // Basic playqueue properties should match + Expect(actual.ID).To(Equal(expected.ID)) + Expect(actual.UserID).To(Equal(expected.UserID)) + Expect(actual.Current).To(Equal(expected.Current)) + Expect(actual.Position).To(Equal(expected.Position)) + Expect(actual.ChangedBy).To(Equal(expected.ChangedBy)) + Expect(actual.Items).To(HaveLen(len(expected.Items))) - By("Storing a new playqueue for the same user") + // Items should only contain IDs, not full MediaFile data + for i, item := range actual.Items { + Expect(item.ID).To(Equal(expected.Items[i].ID)) + // These fields should be empty since we're not loading full MediaFiles + Expect(item.Title).To(BeEmpty()) + Expect(item.Path).To(BeEmpty()) + Expect(item.Album).To(BeEmpty()) + Expect(item.Artist).To(BeEmpty()) + } + }) - another := aPlayQueue("userid", songRadioactivity.ID, 321, songAntenna, songRadioactivity) - Expect(repo.Store(another)).To(Succeed()) + It("returns items with IDs even when some tracks don't exist in the DB", func() { + // Add a new song to the DB + newSong := songRadioactivity + newSong.ID = "temp-track" + newSong.Path = "/new-path" + mfRepo := NewMediaFileRepository(ctx, GetDBXBuilder()) - actual, err = repo.Retrieve("userid") + Expect(mfRepo.Put(&newSong)).To(Succeed()) + + // Create a playqueue with the new song + pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) + Expect(repo.Store(pq)).To(Succeed()) + + // Delete the new song from the database + Expect(mfRepo.Delete("temp-track")).To(Succeed()) + + // Retrieve the playqueue with Retrieve method + actual, err := repo.Retrieve("userid") Expect(err).ToNot(HaveOccurred()) - AssertPlayQueue(another, actual) - Expect(countPlayQueues(repo, "userid")).To(Equal(1)) + // The playqueue should still contain both track IDs (including the deleted one) + Expect(actual.Items).To(HaveLen(2)) + Expect(actual.Items[0].ID).To(Equal("temp-track")) + Expect(actual.Items[1].ID).To(Equal(songAntenna.ID)) + + // Items should only contain IDs, no other data + for _, item := range actual.Items { + Expect(item.Title).To(BeEmpty()) + Expect(item.Path).To(BeEmpty()) + Expect(item.Album).To(BeEmpty()) + Expect(item.Artist).To(BeEmpty()) + } + }) + }) + + Describe("RetrieveWithMediaFiles", func() { + It("returns notfound error if there's no playqueue for the user", func() { + _, err := repo.RetrieveWithMediaFiles("user999") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("retrieves the playqueue with full MediaFile data", func() { + By("Storing a playqueue for the user") + + expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(Succeed()) + + actual, err := repo.RetrieveWithMediaFiles("userid") + Expect(err).ToNot(HaveOccurred()) + + AssertPlayQueue(expected, actual) }) It("does not return tracks if they don't exist in the DB", func() { @@ -62,11 +321,11 @@ var _ = Describe("PlayQueueRepository", func() { Expect(mfRepo.Put(&newSong)).To(Succeed()) // Create a playqueue with the new song - pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna) + pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) Expect(repo.Store(pq)).To(Succeed()) // Retrieve the playqueue - actual, err := repo.Retrieve("userid") + actual, err := repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should contain both tracks @@ -76,7 +335,7 @@ var _ = Describe("PlayQueueRepository", func() { Expect(mfRepo.Delete("temp-track")).To(Succeed()) // Retrieve the playqueue - actual, err = repo.Retrieve("userid") + actual, err = repo.RetrieveWithMediaFiles("userid") Expect(err).ToNot(HaveOccurred()) // The playqueue should not contain the deleted track @@ -84,6 +343,59 @@ var _ = Describe("PlayQueueRepository", func() { Expect(actual.Items[0].ID).To(Equal(songAntenna.ID)) }) }) + + Describe("Clear", func() { + It("clears an existing playqueue", func() { + By("Storing a playqueue") + expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(Succeed()) + + By("Verifying playqueue exists") + _, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + + By("Clearing the playqueue") + Expect(repo.Clear("userid")).To(Succeed()) + + By("Verifying playqueue is cleared") + _, err = repo.Retrieve("userid") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not error when clearing non-existent playqueue", func() { + // Clear should not error even if no playqueue exists + Expect(repo.Clear("nonexistent-user")).To(Succeed()) + }) + + It("only clears the specified user's playqueue", func() { + By("Creating users in the database to avoid foreign key constraints") + userRepo := NewUserRepository(ctx, GetDBXBuilder()) + user1 := &model.User{ID: "user1", UserName: "user1", Name: "User 1", Email: "user1@test.com"} + user2 := &model.User{ID: "user2", UserName: "user2", Name: "User 2", Email: "user2@test.com"} + Expect(userRepo.Put(user1)).To(Succeed()) + Expect(userRepo.Put(user2)).To(Succeed()) + + By("Storing playqueues for two users") + user1Queue := aPlayQueue("user1", 0, 100, songComeTogether) + user2Queue := aPlayQueue("user2", 1, 200, songDayInALife) + Expect(repo.Store(user1Queue)).To(Succeed()) + Expect(repo.Store(user2Queue)).To(Succeed()) + + By("Clearing only user1's playqueue") + Expect(repo.Clear("user1")).To(Succeed()) + + By("Verifying user1's playqueue is cleared") + _, err := repo.Retrieve("user1") + Expect(err).To(MatchError(model.ErrNotFound)) + + By("Verifying user2's playqueue still exists") + actual, err := repo.Retrieve("user2") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.UserID).To(Equal("user2")) + Expect(actual.Current).To(Equal(1)) + Expect(actual.Position).To(Equal(int64(200))) + }) + }) }) func countPlayQueues(repo model.PlayQueueRepository, userId string) int { @@ -107,7 +419,7 @@ func AssertPlayQueue(expected, actual *model.PlayQueue) { } } -func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue { +func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue { createdAt := time.Now() updatedAt := createdAt.Add(time.Minute) return &model.PlayQueue{ diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go index 6962ea7c6..62423ff45 100644 --- a/persistence/scrobble_buffer_repository_test.go +++ b/persistence/scrobble_buffer_repository_test.go @@ -152,7 +152,7 @@ var _ = Describe("ScrobbleBufferRepository", func() { Expect(err).ToNot(HaveOccurred()) Expect(entry).ToNot(BeNil()) - Expect(entry.EnqueueTime).To(BeTemporally("~", now)) + Expect(entry.EnqueueTime).To(BeTemporally("~", now, 100*time.Millisecond)) Expect(entry.MediaFileID).To(Equal(fileId)) Expect(entry.PlayTime).To(BeTemporally("==", playTime)) }, diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 7cc24b6c4..ea22389a2 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -86,6 +86,10 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun // which gives precedence to sort tags. // Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase) // To avoid performance issues, indexes should be created for these sort expressions +// +// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example, +// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly. +// Without parentheses, "lyrics != '[]'" would be mapped as simply "lyrics" func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) { tn := r.tableName if len(tableName) > 0 { diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 4b380e298..7ba2a0021 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -136,6 +136,10 @@ var _ = Describe("sqlRepository", func() { }) Describe("buildSortOrder", func() { + BeforeEach(func() { + r.sortMappings = map[string]string{} + }) + Context("single field", func() { It("sorts by specified field", func() { sql := r.buildSortOrder("name", "desc") @@ -163,6 +167,14 @@ var _ = Describe("sqlRepository", func() { sql := r.buildSortOrder("name desc, age, status asc", "desc") Expect(sql).To(Equal("name asc, age desc, status desc")) }) + It("handles spaces in mapped field", func() { + r.sortMappings = map[string]string{ + "has_lyrics": "(lyrics != '[]'), updated_at", + } + sql := r.buildSortOrder("has_lyrics", "desc") + Expect(sql).To(Equal("(lyrics != '[]') desc, updated_at desc")) + }) + }) Context("function fields", func() { It("handles functions with multiple params", func() { diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 6be368b00..ff0d06a8b 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -1,6 +1,7 @@ package persistence import ( + "cmp" "context" "fmt" "reflect" @@ -105,8 +106,15 @@ func booleanFilter(field string, value any) Sqlizer { return Eq{field: v == "true"} } -func fullTextFilter(tableName string) func(string, any) Sqlizer { - return func(field string, value any) Sqlizer { return fullTextExpr(tableName, value.(string)) } +func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { + return func(field string, value any) Sqlizer { + v := strings.ToLower(value.(string)) + cond := cmp.Or( + mbidExpr(tableName, v, mbidFields...), + fullTextExpr(tableName, v), + ) + return cond + } } func substringFilter(field string, value any) Sqlizer { diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go index 20cc31a36..fd95fbb31 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -2,9 +2,12 @@ package persistence import ( "context" + "strings" "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -66,4 +69,167 @@ var _ = Describe("sqlRestful", func() { Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}})) }) }) + + Describe("fullTextFilter function", func() { + var filter filterFunc + var tableName string + var mbidFields []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tableName = "test_table" + mbidFields = []string{"mbid", "artist_mbid"} + filter = fullTextFilter(tableName, mbidFields...) + }) + + Context("when value is a valid UUID", func() { + It("returns only the mbid filter (precedence over full text)", func() { + uuid := "550e8400-e29b-41d4-a716-446655440000" + result := filter("search", uuid) + + expected := squirrel.Or{ + squirrel.Eq{"test_table.mbid": uuid}, + squirrel.Eq{"test_table.artist_mbid": uuid}, + } + Expect(result).To(Equal(expected)) + }) + + It("falls back to full text when no mbid fields are provided", func() { + noMbidFilter := fullTextFilter(tableName) + uuid := "550e8400-e29b-41d4-a716-446655440000" + result := noMbidFilter("search", uuid) + + // mbidExpr with no fields returns nil, so cmp.Or falls back to fullTextExpr + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"}, + } + Expect(result).To(Equal(expected)) + }) + }) + + Context("when value is not a valid UUID", func() { + It("returns full text search condition only", func() { + result := filter("search", "beatles") + + // mbidExpr returns nil for non-UUIDs, so fullTextExpr result is returned directly + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% beatles%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles multi-word search terms", func() { + result := filter("search", "the beatles abbey road") + + // Should return And condition directly + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(4)) + + // Check that all words are present (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"})) + }) + }) + + Context("when SearchFullString config changes behavior", func() { + It("uses different separator with SearchFullString=false", func() { + conf.Server.SearchFullString = false + result := filter("search", "test query") + + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(2)) + + // Check that all words are present with leading space (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"})) + }) + + It("uses no separator with SearchFullString=true", func() { + conf.Server.SearchFullString = true + result := filter("search", "test query") + + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(2)) + + // Check that all words are present without leading space (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"})) + }) + }) + + Context("edge cases", func() { + It("returns nil for empty string", func() { + result := filter("search", "") + Expect(result).To(BeNil()) + }) + + It("returns nil for string with only whitespace", func() { + result := filter("search", " ") + Expect(result).To(BeNil()) + }) + + It("handles special characters that are sanitized", func() { + result := filter("search", "don't") + + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes + } + Expect(result).To(Equal(expected)) + }) + + It("returns nil for single quote (SQL injection protection)", func() { + result := filter("search", "'") + Expect(result).To(BeNil()) + }) + + It("handles mixed case UUIDs", func() { + uuid := "550E8400-E29B-41D4-A716-446655440000" + result := filter("search", uuid) + + // Should return only mbid filter (uppercase UUID should work) + expected := squirrel.Or{ + squirrel.Eq{"test_table.mbid": strings.ToLower(uuid)}, + squirrel.Eq{"test_table.artist_mbid": strings.ToLower(uuid)}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles invalid UUID format gracefully", func() { + result := filter("search", "550e8400-invalid-uuid") + + // Should return full text filter since UUID is invalid + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles empty mbid fields array", func() { + emptyMbidFilter := fullTextFilter(tableName, []string{}...) + result := emptyMbidFilter("search", "test") + + // mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% test%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("converts value to lowercase before processing", func() { + result := filter("search", "TEST") + + // The function converts to lowercase internally + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% test%"}, + } + Expect(result).To(Equal(expected)) + }) + }) + }) + }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index 9ac171263..3aea958cd 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -4,6 +4,7 @@ import ( "strings" . "github.com/Masterminds/squirrel" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" @@ -21,9 +22,6 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in return nil } - //sq := r.newSelect().Columns(r.tableName + ".*") - //sq = r.withAnnotation(sq, r.tableName+".id") - //sq = r.withBookmark(sq, r.tableName+".id") filter := fullTextExpr(r.tableName, q) if filter != nil { sq = sq.Where(filter) @@ -40,6 +38,28 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, in return r.queryAll(sq, results, model.QueryOptions{Offset: offset}) } +func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, includeMissing bool, results any) error { + sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...)) + + if !includeMissing { + sq = sq.Where(Eq{r.tableName + ".missing": false}) + } + + return r.queryAll(sq, results) +} + +func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { + if uuid.Validate(mbid) != nil || len(mbidFields) == 0 { + return nil + } + mbid = strings.ToLower(mbid) + var cond []Sqlizer + for _, mbidField := range mbidFields { + cond = append(cond, Eq{tableName + "." + mbidField: mbid}) + } + return Or(cond) +} + func fullTextExpr(tableName string, s string) Sqlizer { q := str.SanitizeStrings(s) if q == "" { diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 000000000..31f967879 --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,1721 @@ +# Navidrome Plugin System + +## Overview + +Navidrome's plugin system is a WebAssembly (WASM) based extension mechanism that enables developers to expand Navidrome's functionality without modifying the core codebase. The plugin system supports several capabilities that can be implemented by plugins: + +1. **MetadataAgent** - For fetching artist and album information, images, etc. +2. **Scrobbler** - For implementing scrobbling functionality with external services +3. **SchedulerCallback** - For executing code after a specified delay or on a recurring schedule +4. **WebSocketCallback** - For interacting with WebSocket endpoints and handling WebSocket events +5. **LifecycleManagement** - For plugin initialization and configuration (one-time `OnInit` only; not invoked per-request) + +## Plugin Architecture + +The plugin system is built on the following key components: + +### 1. Plugin Manager + +The `Manager` (implemented in `plugins/manager.go`) is the core component that: + +- Scans for plugins in the configured plugins directory +- Loads and compiles plugins +- Provides access to loaded plugins through capability-specific interfaces + +### 2. Plugin Protocol + +Plugins communicate with Navidrome using Protocol Buffers (protobuf) over a WASM runtime. The protocol is defined in `plugins/api/api.proto` which specifies the capabilities and messages that plugins can implement. + +### 3. Plugin Adapters + +Adapters bridge between the plugin API and Navidrome's internal interfaces: + +- `wasmMediaAgent` adapts `MetadataAgent` to the internal `agents.Interface` +- `wasmScrobblerPlugin` adapts `Scrobbler` to the internal `scrobbler.Scrobbler` +- `wasmSchedulerCallback` adapts `SchedulerCallback` to the internal `SchedulerCallback` + +* **Plugin Instance Pooling**: Instances are managed in an internal pool (default 8 max, 1m TTL). +* **WASM Compilation & Caching**: Modules are pre-compiled concurrently (max 2) and cached in `[CacheFolder]/plugins`, reducing startup time. The compilation timeout can be configured via `DevPluginCompilationTimeout` in development. + +### 4. Host Services + +Navidrome provides host services that plugins can call to access functionality like HTTP requests and scheduling. +These services are defined in `plugins/host/` and implemented in corresponding host files: + +- HTTP service (in `plugins/host_http.go`) for making external requests +- Scheduler service (in `plugins/host_scheduler.go`) for scheduling timed events +- Config service (in `plugins/host_config.go`) for accessing plugin-specific configuration +- WebSocket service (in `plugins/host_websocket.go`) for WebSocket communication +- Cache service (in `plugins/host_cache.go`) for TTL-based plugin caching +- Artwork service (in `plugins/host_artwork.go`) for generating public artwork URLs +- SubsonicAPI service (in `plugins/host_subsonicapi.go`) for accessing Navidrome's Subsonic API + +### Available Host Services + +The following host services are available to plugins: + +#### HttpService + +```protobuf +// HTTP methods available to plugins +service HttpService { + rpc Get(HttpRequest) returns (HttpResponse); + rpc Post(HttpRequest) returns (HttpResponse); + rpc Put(HttpRequest) returns (HttpResponse); + rpc Delete(HttpRequest) returns (HttpResponse); + rpc Patch(HttpRequest) returns (HttpResponse); + rpc Head(HttpRequest) returns (HttpResponse); + rpc Options(HttpRequest) returns (HttpResponse); +} +``` + +#### ConfigService + +```protobuf +service ConfigService { + rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse); +} +``` + +The ConfigService allows plugins to access plugin-specific configuration. See the [config.proto](host/config/config.proto) file for the full API. + +#### ArtworkService + +```protobuf +service ArtworkService { + rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); +} +``` + +Provides methods to get public URLs for artwork images: + +- `GetArtistUrl(id string, size int) string`: Returns a public URL for an artist's artwork +- `GetAlbumUrl(id string, size int) string`: Returns a public URL for an album's artwork +- `GetTrackUrl(id string, size int) string`: Returns a public URL for a track's artwork + +The `size` parameter is optional (use 0 for original size). The URLs returned are based on the server's ShareURL configuration. + +Example: + +```go +url := artwork.GetArtistUrl("123", 300) // Get artist artwork URL with size 300px +url := artwork.GetAlbumUrl("456", 0) // Get album artwork URL in original size +``` + +#### CacheService + +```protobuf +service CacheService { + // Set a string value in the cache + rpc SetString(SetStringRequest) returns (SetResponse); + + // Get a string value from the cache + rpc GetString(GetRequest) returns (GetStringResponse); + + // Set an integer value in the cache + rpc SetInt(SetIntRequest) returns (SetResponse); + + // Get an integer value from the cache + rpc GetInt(GetRequest) returns (GetIntResponse); + + // Set a float value in the cache + rpc SetFloat(SetFloatRequest) returns (SetResponse); + + // Get a float value from the cache + rpc GetFloat(GetRequest) returns (GetFloatResponse); + + // Set a byte slice value in the cache + rpc SetBytes(SetBytesRequest) returns (SetResponse); + + // Get a byte slice value from the cache + rpc GetBytes(GetRequest) returns (GetBytesResponse); + + // Remove a value from the cache + rpc Remove(RemoveRequest) returns (RemoveResponse); + + // Check if a key exists in the cache + rpc Has(HasRequest) returns (HasResponse); +} +``` + +The CacheService provides a TTL-based cache for plugins. Each plugin gets its own isolated cache instance. By default, cached items expire after 24 hours unless a custom TTL is specified. + +Key features: + +- **Isolated Caches**: Each plugin has its own cache namespace, so different plugins can use the same key names without conflicts +- **Typed Values**: Store and retrieve values with their proper types (string, int64, float64, or byte slice) +- **Configurable TTL**: Set custom expiration times per item, or use the default 24-hour TTL +- **Type Safety**: The system handles type checking, returning "not exists" if there's a type mismatch + +Example usage: + +```go +// Store a string value with default TTL (24 hours) +cacheService.SetString(ctx, &cache.SetStringRequest{ + Key: "user_preference", + Value: "dark_mode", +}) + +// Store an integer with custom TTL (5 minutes) +cacheService.SetInt(ctx, &cache.SetIntRequest{ + Key: "api_call_count", + Value: 42, + TtlSeconds: 300, // 5 minutes +}) + +// Retrieve a value +resp, err := cacheService.GetString(ctx, &cache.GetRequest{ + Key: "user_preference", +}) +if err != nil { + // Handle error +} +if resp.Exists { + // Use resp.Value +} else { + // Key doesn't exist or has expired +} + +// Check if a key exists +hasResp, err := cacheService.Has(ctx, &cache.HasRequest{ + Key: "api_call_count", +}) +if hasResp.Exists { + // Key exists and hasn't expired +} + +// Remove a value +cacheService.Remove(ctx, &cache.RemoveRequest{ + Key: "user_preference", +}) +``` + +See the [cache.proto](host/cache/cache.proto) file for the full API definition. + +#### SchedulerService + +The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API. + +```protobuf +service SchedulerService { + // One-time event scheduling + rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse); + + // Recurring event scheduling + rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse); + + // Cancel any scheduled job + rpc CancelSchedule(CancelRequest) returns (CancelResponse); +} +``` + +- **One-time scheduling**: Schedule a callback to be executed once after a specified delay. +- **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression. + +Plugins using this service must implement the `SchedulerCallback` interface: + +```protobuf +service SchedulerCallback { + rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse); +} +``` + +The `IsRecurring` field in the request allows plugins to differentiate between one-time and recurring callbacks. + +#### WebSocketService + +The WebSocketService enables plugins to connect to and interact with WebSocket endpoints. See the [websocket.proto](host/websocket/websocket.proto) file for the full API. + +```protobuf +service WebSocketService { + // Connect to a WebSocket endpoint + rpc Connect(ConnectRequest) returns (ConnectResponse); + + // Send a text message + rpc SendText(SendTextRequest) returns (SendTextResponse); + + // Send binary data + rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse); + + // Close a connection + rpc Close(CloseRequest) returns (CloseResponse); +} +``` + +- **Connect**: Establish a WebSocket connection to a specified URL with optional headers +- **SendText**: Send text messages over an established connection +- **SendBinary**: Send binary data over an established connection +- **Close**: Close a WebSocket connection with optional close code and reason + +Plugins using this service must implement the `WebSocketCallback` interface to handle incoming messages and connection events: + +```protobuf +service WebSocketCallback { + rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse); + rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse); + rpc OnError(OnErrorRequest) returns (OnErrorResponse); + rpc OnClose(OnCloseRequest) returns (OnCloseResponse); +} +``` + +Example usage: + +```go +// Connect to a WebSocket server +connectResp, err := websocket.Connect(ctx, &websocket.ConnectRequest{ + Url: "wss://example.com/ws", + Headers: map[string]string{"Authorization": "Bearer token"}, + ConnectionId: "my-connection-id", +}) +if err != nil { + return err +} + +// Send a text message +_, err = websocket.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: "my-connection-id", + Message: "Hello WebSocket", +}) + +// Send binary data +_, err = websocket.SendBinary(ctx, &websocket.SendBinaryRequest{ + ConnectionId: "my-connection-id", + Data: []byte{0x01, 0x02, 0x03}, +}) + +// Close the connection when done +_, err = websocket.Close(ctx, &websocket.CloseRequest{ + ConnectionId: "my-connection-id", + Code: 1000, // Normal closure + Reason: "Done", +}) +``` + +#### SubsonicAPIService + +```protobuf +service SubsonicAPIService { + rpc Call(CallRequest) returns (CallResponse); +} +``` + +The SubsonicAPIService provides plugins with access to Navidrome's Subsonic API endpoints. This allows plugins to query and interact with Navidrome's music library data using the same API that external Subsonic clients use. + +Key features: + +- **Library Access**: Query artists, albums, tracks, playlists, and other music library data +- **Search Functionality**: Search across the music library using various criteria +- **Metadata Retrieval**: Get detailed information about music items including ratings, play counts, etc. +- **Authentication Handled**: The service automatically handles authentication using internal auth context +- **JSON Responses**: All responses are returned as JSON strings for easy parsing + +**Important Security Notes:** + +- Plugins must specify a username via the `u` parameter in the URL - this determines which user's library view and permissions apply +- The service uses internal authentication, so plugins don't need to provide passwords or API keys +- All Subsonic API security and access controls apply based on the specified user + +Example usage: + +```go +// Get ping response to test connectivity +resp, err := subsonicAPI.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", +}) +if err != nil { + return err +} +// resp.Json contains the JSON response + +// Search for artists +resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/search3?u=admin&query=Beatles&artistCount=10", +}) + +// Get album details +resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/getAlbum?u=admin&id=123", +}) + +// Check for errors +if resp.Error != "" { + // Handle error - could be missing parameters, invalid user, etc. + log.Printf("SubsonicAPI error: %s", resp.Error) +} +``` + +**Common URL Patterns:** + +- `/rest/ping?u=USERNAME` - Test API connectivity +- `/rest/search3?u=USERNAME&query=TERM` - Search library +- `/rest/getArtists?u=USERNAME` - Get all artists +- `/rest/getAlbum?u=USERNAME&id=ID` - Get album details +- `/rest/getPlaylists?u=USERNAME` - Get user playlists + +**Required Parameters:** + +- `u` (username): Required for all requests - determines user context and permissions +- `f=json`: Recommended to get JSON responses (easier to parse than XML) + +The service accepts standard Subsonic API endpoints and parameters. Refer to the [Subsonic API documentation](http://www.subsonic.org/pages/api.jsp) for complete endpoint details, but note that authentication parameters (`p`, `t`, `s`, `c`, `v`) are handled automatically. + +See the [subsonicapi.proto](host/subsonicapi/subsonicapi.proto) file for the full API definition. + +## Plugin Permission System + +Navidrome implements a permission-based security system that controls which host services plugins can access. This system enforces security at load-time by only making authorized services available to plugins in their WebAssembly runtime environment. + +### How Permissions Work + +The permission system follows a **secure-by-default** approach: + +1. **Default Behavior**: Plugins have access to **no host services** unless explicitly declared +2. **Load-time Enforcement**: Only services listed in a plugin's permissions are loaded into its WASM runtime +3. **Runtime Security**: Unauthorized services are completely unavailable - attempts to call them result in "function not exported" errors + +This design ensures that even if malicious code tries to access unauthorized services, the calls will fail because the functions simply don't exist in the plugin's runtime environment. + +### Permission Syntax + +Permissions are declared in the plugin's `manifest.json` file using the `permissions` field as an object: + +```json +{ + "name": "my-plugin", + "author": "Plugin Developer", + "version": "1.0.0", + "description": "A plugin that fetches data and caches results", + "website": "https://github.com/plugindeveloper/my-plugin", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch metadata from external APIs", + "allowedUrls": { + "https://api.musicbrainz.org": ["GET"], + "https://coverartarchive.org": ["GET"] + }, + "allowLocalNetwork": false + }, + "cache": { + "reason": "To cache API responses and reduce rate limiting" + }, + "subsonicapi": { + "reason": "To query music library for artist and album information", + "allowedUsernames": ["metadata-user"], + "allowAdmins": false + } + } +} +``` + +Each permission is represented as a key in the permissions object. The value must be an object containing a `reason` field that explains why the permission is needed. + +**Important**: Some permissions require additional configuration fields: + +- **`http`**: Requires `allowedUrls` object mapping URL patterns to allowed HTTP methods, and optional `allowLocalNetwork` boolean +- **`websocket`**: Requires `allowedUrls` array of WebSocket URL patterns, and optional `allowLocalNetwork` boolean +- **`subsonicapi`**: Requires `reason` field, with optional `allowedUsernames` array and `allowAdmins` boolean for fine-grained access control +- **`config`**, **`cache`**, **`scheduler`**, **`artwork`**: Only require the `reason` field + +**Security Benefits of Required Reasons:** + +- **Transparency**: Users can see exactly what each plugin will do with its permissions +- **Security Auditing**: Makes it easier to identify suspicious or overly broad permission requests +- **Developer Accountability**: Forces plugin authors to justify each permission they request +- **Trust Building**: Clear explanations help users make informed decisions about plugin installation + +If no permissions are needed, use an empty permissions object: `"permissions": {}`. + +### Available Permissions + +The following permission keys correspond to host services: + +| Permission | Host Service | Description | Required Fields | +|---------------|--------------------|----------------------------------------------------|-------------------------------------------------------| +| `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` | +| `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` | +| `cache` | CacheService | Store and retrieve cached data with TTL | `reason` | +| `config` | ConfigService | Access Navidrome configuration values | `reason` | +| `scheduler` | SchedulerService | Schedule one-time and recurring tasks | `reason` | +| `artwork` | ArtworkService | Generate public URLs for artwork images | `reason` | +| `subsonicapi` | SubsonicAPIService | Access Navidrome's Subsonic API endpoints | `reason`, optional: `allowedUsernames`, `allowAdmins` | + +#### HTTP Permission Structure + +HTTP permissions require explicit URL whitelisting for security: + +```json +{ + "http": { + "reason": "To fetch artist data from MusicBrainz and album covers from Cover Art Archive", + "allowedUrls": { + "https://musicbrainz.org/ws/2/*": ["GET"], + "https://coverartarchive.org/*": ["GET"], + "https://api.example.com/submit": ["POST"] + }, + "allowLocalNetwork": false + } +} +``` + +**Fields:** + +- `reason` (required): Explanation of why HTTP access is needed +- `allowedUrls` (required): Object mapping URL patterns to allowed HTTP methods +- `allowLocalNetwork` (optional, default false): Whether to allow requests to localhost/private IPs + +**URL Pattern Matching:** + +- Exact URLs: `"https://api.example.com/endpoint": ["GET"]` +- Wildcard paths: `"https://api.example.com/*": ["GET", "POST"]` +- Subdomain wildcards: `"https://*.example.com": ["GET"]` + +**Important**: Redirect destinations must also be included in `allowedUrls` if you want to follow redirects. + +#### WebSocket Permission Structure + +WebSocket permissions require explicit URL whitelisting: + +```json +{ + "websocket": { + "reason": "To connect to Discord gateway for real-time Rich Presence updates", + "allowedUrls": ["wss://gateway.discord.gg", "wss://*.discord.gg"], + "allowLocalNetwork": false + } +} +``` + +**Fields:** + +- `reason` (required): Explanation of why WebSocket access is needed +- `allowedUrls` (required): Array of WebSocket URL patterns (must start with `ws://` or `wss://`) +- `allowLocalNetwork` (optional, default false): Whether to allow connections to localhost/private IPs + +#### SubsonicAPI Permission Structure + +SubsonicAPI permissions control which users plugins can access Navidrome's Subsonic API as, providing fine-grained security controls: + +```json +{ + "subsonicapi": { + "reason": "To query music library data for recommendation engine", + "allowedUsernames": ["plugin-user", "readonly-user"], + "allowAdmins": false + } +} +``` + +**Fields:** + +- `reason` (required): Explanation of why SubsonicAPI access is needed +- `allowedUsernames` (optional): Array of specific usernames the plugin is allowed to use. If empty or omitted, any username can be used +- `allowAdmins` (optional, default false): Whether the plugin can make API calls using admin user accounts + +**Security Model:** + +The SubsonicAPI service enforces strict user-based access controls: + +- **Username Validation**: The plugin must provide a valid `u` (username) parameter in all API calls +- **User Context**: All API responses are filtered based on the specified user's permissions and library access +- **Admin Protection**: By default, plugins cannot use admin accounts for API calls to prevent privilege escalation +- **Username Restrictions**: When `allowedUsernames` is specified, only those users can be used + +**Common Permission Patterns:** + +```jsonc +// Allow any non-admin user (most permissive) +{ + "subsonicapi": { + "reason": "To search music library for metadata enhancement", + "allowAdmins": false + } +} + +// Allow only specific users (most secure) +{ + "subsonicapi": { + "reason": "To access playlists for synchronization with external service", + "allowedUsernames": ["sync-user"], + "allowAdmins": false + } +} + +// Allow admin users (use with caution) +{ + "subsonicapi": { + "reason": "To perform administrative tasks like library statistics", + "allowAdmins": true + } +} + +// Restrict to specific users but allow admins +{ + "subsonicapi": { + "reason": "To backup playlists for authorized users only", + "allowedUsernames": ["backup-admin", "user1", "user2"], + "allowAdmins": true + } +} +``` + +**Important Notes:** + +- Username matching is case-insensitive +- If `allowedUsernames` is empty or omitted, any username can be used (subject to `allowAdmins` setting) +- Admin restriction (`allowAdmins: false`) is checked after username validation +- Invalid or non-existent usernames will result in API call errors + +### Permission Validation + +The plugin system validates permissions during loading: + +1. **Schema Validation**: The manifest is validated against the JSON schema +2. **Permission Recognition**: Unknown permission keys are silently accepted for forward compatibility +3. **Service Loading**: Only services with corresponding permissions are made available to the plugin + +### Security Model + +The permission system provides multiple layers of security: + +#### 1. Principle of Least Privilege + +- Plugins start with zero permissions +- Only explicitly requested services are available +- No way to escalate privileges at runtime + +#### 2. Load-time Enforcement + +- Unauthorized services are not loaded into the WASM runtime +- No performance overhead for permission checks during execution +- Impossible to bypass restrictions through code manipulation + +#### 3. Service Isolation + +- Each plugin gets its own isolated service instances +- Plugins cannot interfere with each other's service usage +- Host services are sandboxed within the WASM environment + +### Best Practices for Plugin Developers + +#### Request Minimal Permissions + +```jsonc +// Good: No permissions if none needed +{ + "permissions": {} +} + +// Good: Only request what you need with clear reasoning +{ + "permissions": { + "http": { + "reason": "To fetch artist biography from MusicBrainz database", + "allowedUrls": { + "https://musicbrainz.org/ws/2/artist/*": ["GET"] + }, + "allowLocalNetwork": false + } + } +} + +// Avoid: Requesting unnecessary permissions +{ + "permissions": { + "http": { + "reason": "To fetch data", + "allowedUrls": { + "https://*": ["*"] + }, + "allowLocalNetwork": true + }, + "cache": { + "reason": "For caching" + }, + "scheduler": { + "reason": "For scheduling" + }, + "websocket": { + "reason": "For real-time updates", + "allowedUrls": ["wss://*"], + "allowLocalNetwork": true + } + } +} +``` + +#### Write Clear Permission Reasons + +Provide specific, descriptive reasons for each permission that explain exactly what the plugin does. Good reasons should: + +- Specify **what data** will be accessed/fetched +- Mention **which external services** will be contacted (if applicable) +- Explain **why** the permission is necessary for the plugin's functionality +- Use clear, non-technical language that users can understand + +```jsonc +// Good: Specific and informative +{ + "http": { + "reason": "To fetch album reviews from AllMusic API and artist biographies from MusicBrainz", + "allowedUrls": { + "https://www.allmusic.com/api/*": ["GET"], + "https://musicbrainz.org/ws/2/*": ["GET"] + }, + "allowLocalNetwork": false + }, + "cache": { + "reason": "To cache API responses for 24 hours to respect rate limits and improve performance" + } +} + +// Bad: Vague and unhelpful +{ + "http": { + "reason": "To make requests", + "allowedUrls": { + "https://*": ["*"] + }, + "allowLocalNetwork": true + }, + "cache": { + "reason": "For caching" + } +} +``` + +#### Handle Missing Permissions Gracefully + +Your plugin should provide clear error messages when permissions are missing: + +```go +func (p *Plugin) GetArtistInfo(ctx context.Context, req *api.ArtistInfoRequest) (*api.ArtistInfoResponse, error) { + // This will fail with "function not exported" if http permission is missing + resp, err := p.httpClient.Get(ctx, &http.HttpRequest{Url: apiURL}) + if err != nil { + // Check if it's a permission error + if strings.Contains(err.Error(), "not exported") { + return &api.ArtistInfoResponse{ + Error: "Plugin requires 'http' permission (reason: 'To fetch artist metadata from external APIs') - please add to manifest.json", + }, nil + } + return &api.ArtistInfoResponse{Error: err.Error()}, nil + } + // ... process response +} +``` + +### Troubleshooting Permissions + +#### Common Error Messages + +**"function not exported in module env"** + +- Cause: Plugin trying to call a service without proper permission +- Solution: Add the required permission to your manifest.json + +**"manifest validation failed" or "missing required field"** + +- Cause: Plugin manifest is missing required fields (e.g., `allowedUrls` for HTTP/WebSocket permissions) +- Solution: Ensure your manifest includes all required fields for each permission type + +**Permission silently ignored** + +- Cause: Using a permission key not recognized by current Navidrome version +- Effect: The unknown permission is silently ignored (no error or warning) +- Solution: This is actually normal behavior for forward compatibility + +#### Debugging Permission Issues + +1. **Check the manifest**: Ensure required permissions are spelled correctly and present +2. **Verify required fields**: Check that HTTP and WebSocket permissions include `allowedUrls` and other required fields +3. **Review logs**: Check for plugin loading errors, manifest validation errors, and WASM runtime errors +4. **Test incrementally**: Add permissions one at a time to identify which services your plugin needs +5. **Verify service names**: Ensure permission keys match exactly: `http`, `cache`, `config`, `scheduler`, `websocket`, `artwork`, `subsonicapi` +6. **Validate manifest**: Use a JSON schema validator to check your manifest against the schema + +### Future Considerations + +The permission system is designed for extensibility: + +- **Unknown permissions** are allowed in manifests for forward compatibility +- **New services** can be added with corresponding permission keys +- **Permission scoping** could be added in the future (e.g., read-only vs. read-write access) + +This ensures that plugins developed today will continue to work as the system evolves, while maintaining strong security boundaries. + +## Plugin System Implementation + +Navidrome's plugin system is built using the following key libraries: + +### 1. WebAssembly Runtime (Wazero) + +The plugin system uses [Wazero](https://github.com/tetratelabs/wazero), a WebAssembly runtime written in pure Go. Wazero was chosen for several reasons: + +- **No CGO dependency**: Unlike other WebAssembly runtimes, Wazero is implemented in pure Go, which simplifies cross-compilation and deployment. +- **Performance**: It provides efficient compilation and caching of WebAssembly modules. +- **Security**: Wazero enforces strict sandboxing, which is important for running third-party plugin code safely. + +The plugin manager uses Wazero to: + +- Compile and cache WebAssembly modules +- Create isolated runtime environments for each plugin +- Instantiate plugin modules when they're called +- Provide host functions that plugins can call + +### 2. Go-plugin Framework + +Navidrome builds on [go-plugin](https://github.com/knqyf263/go-plugin), a Go plugin system over WebAssembly that provides: + +- **Code generation**: Custom Protocol Buffer compiler plugin (`protoc-gen-go-plugin`) that generates Go code for both the host and WebAssembly plugins +- **Host function system**: Framework for exposing host functionality to plugins safely +- **Interface versioning**: Built-in mechanism for handling API compatibility between the host and plugins +- **Type conversion**: Utilities for marshaling and unmarshaling data between Go and WebAssembly + +This framework significantly simplifies plugin development by handling the low-level details of WebAssembly communication, allowing plugin developers to focus on implementing capabilities interfaces. + +### 3. Protocol Buffers (Protobuf) + +[Protocol Buffers](https://developers.google.com/protocol-buffers) serve as the interface definition language for the plugin system. Navidrome uses: + +- **protoc-gen-go-plugin**: A custom protobuf compiler plugin that generates Go code for both the Navidrome host and WebAssembly plugins +- Protobuf messages for structured data exchange between the host and plugins + +The protobuf definitions are located in: + +- `plugins/api/api.proto`: Core plugin capability interfaces +- `plugins/host/http/http.proto`: HTTP service interface +- `plugins/host/scheduler/scheduler.proto`: Scheduler service interface +- `plugins/host/config/config.proto`: Config service interface +- `plugins/host/websocket/websocket.proto`: WebSocket service interface +- `plugins/host/cache/cache.proto`: Cache service interface +- `plugins/host/artwork/artwork.proto`: Artwork service interface +- `plugins/host/subsonicapi/subsonicapi.proto`: SubsonicAPI service interface + +### 4. Integration Architecture + +The plugin system integrates these libraries through several key components: + +- **Plugin Manager**: Manages the lifecycle of plugins, from discovery to loading +- **Compilation Cache**: Improves performance by caching compiled WebAssembly modules +- **Host Function Bridge**: Exposes Navidrome functionality to plugins through WebAssembly imports +- **Capability Adapters**: Convert between the plugin API and Navidrome's internal interfaces + +Each plugin method call: + +1. Creates a new isolated plugin instance using Wazero +2. Executes the method in the sandboxed environment +3. Converts data between Go and WebAssembly formats using the protobuf-generated code +4. Cleans up the instance after the call completes + +This stateless design ensures that plugins remain isolated and can't interfere with Navidrome's core functionality or each other. + +## Configuration + +Plugins are configured in Navidrome's main configuration via the `Plugins` section: + +```toml +[Plugins] +# Enable or disable plugin support +Enabled = true + +# Directory where plugins are stored (defaults to [DataFolder]/plugins) +Folder = "/path/to/plugins" +``` + +By default, the plugins folder is created under `[DataFolder]/plugins` with restrictive permissions (`0700`) to limit access to the Navidrome user. + +### Plugin-specific Configuration + +You can also provide plugin-specific configuration using the `PluginConfig` section. Each plugin can have its own configuration map using the **folder name** as the key: + +```toml +[PluginConfig.my-plugin-folder] +api_key = "your-api-key" +user_id = "your-user-id" +enable_feature = "true" + +[PluginConfig.another-plugin-folder] +server_url = "https://example.com/api" +timeout = "30" +``` + +These configuration values are passed to plugins during initialization through the `OnInit` method in the `LifecycleManagement` capability. +Plugins that implement the `LifecycleManagement` capability will receive their configuration as a map of string keys and values. + +## Plugin Directory Structure + +Each plugin must be located in its own directory under the plugins folder: + +``` +plugins/ +├── my-plugin/ +│ ├── plugin.wasm # Compiled WebAssembly module +│ └── manifest.json # Plugin manifest defining metadata and capabilities +├── another-plugin/ +│ ├── plugin.wasm +│ └── manifest.json +``` + +**Note**: Plugin identification has changed! Navidrome now uses the **folder name** as the unique identifier for plugins, not the `name` field in `manifest.json`. This means: + +- **Multiple plugins can have the same `name` in their manifest**, as long as they are in different folders +- **Plugin loading and commands use the folder name**, not the manifest name +- **Folder names must be unique** across all plugins in your plugins directory + +This change allows you to have multiple versions or variants of the same plugin (e.g., `lastfm-official`, `lastfm-custom`, `lastfm-dev`) that all have the same manifest name but coexist peacefully. + +### Example: Multiple Plugin Variants + +``` +plugins/ +├── lastfm-official/ +│ ├── plugin.wasm +│ └── manifest.json # {"name": "LastFM Agent", ...} +├── lastfm-custom/ +│ ├── plugin.wasm +│ └── manifest.json # {"name": "LastFM Agent", ...} +└── lastfm-dev/ + ├── plugin.wasm + └── manifest.json # {"name": "LastFM Agent", ...} +``` + +All three plugins can have the same `"name": "LastFM Agent"` in their manifest, but they are identified and loaded by their folder names: + +```bash +# Load specific variants +navidrome plugin refresh lastfm-official +navidrome plugin refresh lastfm-custom +navidrome plugin refresh lastfm-dev + +# Configure each variant separately +[PluginConfig.lastfm-official] +api_key = "production-key" + +[PluginConfig.lastfm-dev] +api_key = "development-key" +``` + +### Using Symlinks for Plugin Variants + +Symlinks provide a powerful way to create multiple configurations for the same plugin without duplicating files. When you create a symlink to a plugin directory, Navidrome treats the symlink as a separate plugin with its own configuration. + +**Example: Discord Rich Presence with Multiple Configurations** + +```bash +# Create symlinks for different environments +cd /path/to/navidrome/plugins +ln -s /path/to/discord-rich-presence-plugin drp-prod +ln -s /path/to/discord-rich-presence-plugin drp-dev +ln -s /path/to/discord-rich-presence-plugin drp-test +``` + +Directory structure: + +``` +plugins/ +├── drp-prod -> /path/to/discord-rich-presence-plugin/ +├── drp-dev -> /path/to/discord-rich-presence-plugin/ +├── drp-test -> /path/to/discord-rich-presence-plugin/ +``` + +Each symlink can have its own configuration: + +```toml +[PluginConfig.drp-prod] +clientid = "production-client-id" +users = "admin:prod-token" + +[PluginConfig.drp-dev] +clientid = "development-client-id" +users = "admin:dev-token,testuser:test-token" + +[PluginConfig.drp-test] +clientid = "test-client-id" +users = "testuser:test-token" +``` + +**Key Benefits:** + +- **Single Source**: One plugin implementation serves multiple use cases +- **Independent Configuration**: Each symlink has its own configuration namespace +- **Development Workflow**: Easy to test different configurations without code changes +- **Resource Sharing**: All symlinks share the same compiled WASM binary + +**Important Notes:** + +- The **symlink name** (not the target folder name) is used as the plugin ID +- Configuration keys use the symlink name: `PluginConfig.` +- Each symlink appears as a separate plugin in `navidrome plugin list` +- CLI commands use the symlink name: `navidrome plugin refresh drp-dev` + +## Plugin Package Format (.ndp) + +Navidrome Plugin Packages (.ndp) are ZIP archives that bundle all files needed for a plugin. They can be installed using the `navidrome plugin install` command. + +### Package Structure + +A valid .ndp file must contain: + +``` +plugin-name.ndp (ZIP file) +├── plugin.wasm # Required: The compiled WebAssembly module +├── manifest.json # Required: Plugin manifest with metadata +├── README.md # Optional: Documentation +└── LICENSE # Optional: License information +``` + +### Creating a Plugin Package + +To create a plugin package: + +1. Compile your plugin to WebAssembly (plugin.wasm) +2. Create a manifest.json file with required fields +3. Include any documentation files you want to bundle +4. Create a ZIP archive of all files +5. Rename the ZIP file to have a .ndp extension + +### Installing a Plugin Package + +Use the Navidrome CLI to install plugins: + +```bash +navidrome plugin install /path/to/plugin-name.ndp +``` + +This will extract the plugin to a directory in your configured plugins folder. + +## Plugin Management + +Navidrome provides a command-line interface for managing plugins. To use these commands, the plugin system must be enabled in your configuration. + +### Available Commands + +```bash +# List all installed plugins +navidrome plugin list + +# Show detailed information about a plugin package or installed plugin +navidrome plugin info plugin-name-or-package.ndp + +# Install a plugin from a .ndp file +navidrome plugin install /path/to/plugin.ndp + +# Remove an installed plugin (use folder name) +navidrome plugin remove plugin-folder-name + +# Update an existing plugin +navidrome plugin update /path/to/updated-plugin.ndp + +# Reload a plugin without restarting Navidrome (use folder name) +navidrome plugin refresh plugin-folder-name + +# Create a symlink to a plugin development folder +navidrome plugin dev /path/to/dev/folder +``` + +### Plugin Development + +The `dev` and `refresh` commands are particularly useful for plugin development: + +#### Development Workflow + +1. Create a plugin development folder with required files (`manifest.json` and `plugin.wasm`) +2. Run `navidrome plugin dev /path/to/your/plugin` to create a symlink in the plugins directory +3. Make changes to your plugin code +4. Recompile the WebAssembly module +5. Run `navidrome plugin refresh your-plugin-folder-name` to reload the plugin without restarting Navidrome + +The `dev` command creates a symlink from your development folder to the plugins directory, allowing you to edit the plugin files directly in your development environment without copying them to the plugins directory after each change. + +The refresh process: + +- Reloads the plugin manifest +- Recompiles the WebAssembly module +- Updates the plugin registration +- Makes the updated plugin immediately available to Navidrome + +### Plugin Security + +Navidrome provides multiple layers of security for plugin execution: + +1. **WebAssembly Sandbox**: Plugins run in isolated WebAssembly environments with no direct system access +2. **Permission System**: Plugins can only access host services they explicitly request in their manifest (see [Plugin Permission System](#plugin-permission-system)) +3. **File System Security**: The plugins folder is configured with restricted permissions (0700) accessible only by the user running Navidrome +4. **Resource Isolation**: Each plugin instance is isolated and cannot interfere with other plugins or core Navidrome functionality + +The permission system ensures that plugins follow the principle of least privilege - they start with no access to host services and must explicitly declare what they need. This prevents malicious or poorly written plugins from accessing unauthorized functionality. + +Always ensure you trust the source of any plugins you install, and review their requested permissions before installation. + +## Plugin Manifest + +**Capability Names Are Case-Sensitive**: Entries in the `capabilities` array must exactly match one of the supported capabilities: `MetadataAgent`, `Scrobbler`, `SchedulerCallback`, `WebSocketCallback`, or `LifecycleManagement`. +**Manifest Validation**: The `manifest.json` is validated against the embedded JSON schema (`plugins/schema/manifest.schema.json`). Invalid manifests will be rejected during plugin discovery. + +Every plugin must provide a `manifest.json` file that declares metadata, capabilities, and permissions: + +```json +{ + "name": "my-awesome-plugin", + "author": "Your Name", + "version": "1.0.0", + "description": "A plugin that does awesome things", + "website": "https://github.com/yourname/my-awesome-plugin", + "capabilities": [ + "MetadataAgent", + "Scrobbler", + "SchedulerCallback", + "WebSocketCallback", + "LifecycleManagement" + ], + "permissions": { + "http": { + "reason": "To fetch metadata from external music APIs" + }, + "cache": { + "reason": "To cache API responses and reduce rate limiting" + }, + "config": { + "reason": "To read API keys and service configuration" + }, + "scheduler": { + "reason": "To schedule periodic data refresh tasks" + } + } +} +``` + +Required fields: + +- `name`: Display name of the plugin (used for documentation/display purposes; folder name is used for identification) +- `author`: The creator or organization behind the plugin +- `version`: Version identifier (recommended to follow semantic versioning) +- `description`: A brief description of what the plugin does +- `website`: Website URL for the plugin documentation, source code, or homepage (must be a valid URI) +- `capabilities`: Array of capability types the plugin implements +- `permissions`: Object mapping host service names to their configurations (use empty object `{}` for no permissions) + +Currently supported capabilities: + +- `MetadataAgent` - For implementing media metadata providers +- `Scrobbler` - For implementing scrobbling plugins +- `SchedulerCallback` - For implementing timed callbacks +- `WebSocketCallback` - For interacting with WebSocket endpoints and handling WebSocket events +- `LifecycleManagement` - For handling plugin initialization and configuration + +## Plugin Loading Process + +1. The Plugin Manager scans the plugins directory and all subdirectories +2. For each subdirectory containing a `plugin.wasm` file and valid `manifest.json`, the manager: + - Validates the manifest and checks for supported capabilities + - Pre-compiles the WASM module in the background + - Registers the plugin using the **folder name** as the unique identifier in the plugin registry +3. Plugins can be loaded on-demand by folder name or all at once, depending on the manager's method calls + +## Writing a Plugin + +### Requirements + +1. Your plugin must be compiled to WebAssembly (WASM) +2. Your plugin must implement at least one of the capability interfaces defined in `api.proto` +3. Your plugin must be placed in its own directory with a proper `manifest.json` + +### Plugin Registration Functions + +The plugin API provides several registration functions that plugins can call during initialization to register capabilities and obtain host services. These functions should typically be called in your plugin's `init()` function. + +#### Standard Registration Functions + +```go +func RegisterMetadataAgent(agent MetadataAgent) +func RegisterScrobbler(scrobbler Scrobbler) +func RegisterSchedulerCallback(callback SchedulerCallback) +func RegisterLifecycleManagement(lifecycle LifecycleManagement) +func RegisterWebSocketCallback(callback WebSocketCallback) +``` + +These functions register plugins for the standard capability interfaces: + +- **RegisterMetadataAgent**: Register a plugin that provides artist/album metadata and images +- **RegisterScrobbler**: Register a plugin that handles scrobbling to external services +- **RegisterSchedulerCallback**: Register a plugin that handles scheduled callbacks (single callback per plugin) +- **RegisterLifecycleManagement**: Register a plugin that handles initialization and configuration +- **RegisterWebSocketCallback**: Register a plugin that handles WebSocket events + +**Basic Usage Example:** + +```go +type MyPlugin struct { + // plugin implementation +} + +func init() { + plugin := &MyPlugin{} + + // Register capabilities your plugin implements + api.RegisterScrobbler(plugin) + api.RegisterLifecycleManagement(plugin) +} +``` + +#### RegisterNamedSchedulerCallback + +```go +func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService +``` + +This function registers a named scheduler callback and returns a scheduler service instance. Named callbacks allow a single plugin to register multiple scheduler callbacks for different purposes, each with its own identifier. + +**Parameters:** + +- `name` (string): A unique identifier for this scheduler callback within the plugin. This name is used to route scheduled events to the correct callback handler. +- `cb` (SchedulerCallback): An object that implements the `SchedulerCallback` interface + +**Returns:** + +- `scheduler.SchedulerService`: A scheduler service instance that can be used to schedule one-time or recurring tasks for this specific callback + +**Usage Example** (from Discord Rich Presence plugin): + +```go +func init() { + // Register multiple named scheduler callbacks for different purposes + plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin) + plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc) +} + +// The plugin implements SchedulerCallback to handle "close-activity" events +func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + log.Printf("Removing presence for user %s", req.ScheduleId) + // Handle close-activity scheduling events + return nil, d.rpc.clearActivity(ctx, req.ScheduleId) +} + +// The rpc component implements SchedulerCallback to handle "heartbeat" events +func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + // Handle heartbeat scheduling events + return nil, r.sendHeartbeat(ctx, req.ScheduleId) +} + +// Use the returned scheduler service to schedule tasks +func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) { + // Schedule a one-time callback to clear activity when track ends + _, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{ + ScheduleId: request.Username, + DelaySeconds: request.Track.Length - request.Track.Position + 5, + }) + return nil, err +} + +func (r *discordRPC) connect(ctx context.Context, username string, token string) error { + // Schedule recurring heartbeats for Discord connection + _, err := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + CronExpression: "@every 41s", + ScheduleId: username, + }) + return err +} +``` + +**Key Benefits:** + +- **Multiple Schedulers**: A single plugin can have multiple named scheduler callbacks for different purposes (e.g., "heartbeat", "cleanup", "refresh") +- **Isolated Scheduling**: Each named callback gets its own scheduler service, allowing independent scheduling management +- **Clear Separation**: Different callback handlers can be implemented on different objects within your plugin +- **Flexible Routing**: The scheduler automatically routes callbacks to the correct handler based on the registration name + +**Important Notes:** + +- The `name` parameter must be unique within your plugin, but can be the same across different plugins +- The returned scheduler service is specifically tied to the named callback you registered +- Scheduled events will call the `OnSchedulerCallback` method on the object you provided during registration +- You must implement the `SchedulerCallback` interface on the object you register + +#### RegisterSchedulerCallback vs RegisterNamedSchedulerCallback + +- **Use `RegisterSchedulerCallback`** when your plugin only needs a single scheduler callback +- **Use `RegisterNamedSchedulerCallback`** when your plugin needs multiple scheduler callbacks for different purposes (like the Discord plugin's "heartbeat" and "close-activity" callbacks) + +The named version allows better organization and separation of concerns when you have complex scheduling requirements. + +### Capability Interfaces + +#### Metadata Agent + +A capability fetches metadata about artists and albums. Implement this interface to add support for fetching data from external sources. + +```protobuf +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); +} +``` + +#### Scrobbler + +This capability enables scrobbling to external services. Implement this interface to add support for custom scrobblers. + +```protobuf +service Scrobbler { + rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse); + rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse); + rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse); +} +``` + +#### Scheduler Callback + +This capability allows plugins to receive one-time or recurring scheduled callbacks. Implement this interface to add +support for scheduled tasks. See the [SchedulerService](#scheduler-service) for more information. + +```protobuf +service SchedulerCallback { + rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse); +} +``` + +#### WebSocket Callback + +This capability allows plugins to interact with WebSocket endpoints and handle WebSocket events. Implement this interface to add support for WebSocket-based communication. + +```protobuf +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); +} +``` + +Plugins can use the WebSocket host service to connect to WebSocket endpoints, send messages, and handle responses: + +```go +// Define a connection ID first +connectionID := "my-connection-id" + +// Connect to a WebSocket server +connectResp, err := websocket.Connect(ctx, &websocket.ConnectRequest{ + Url: "wss://example.com/ws", + Headers: map[string]string{"Authorization": "Bearer token"}, + ConnectionId: connectionID, +}) +if err != nil { + return err +} + +// Send a text message +_, err = websocket.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: connectionID, + Message: "Hello WebSocket", +}) + +// Close the connection when done +_, err = websocket.Close(ctx, &websocket.CloseRequest{ + ConnectionId: connectionID, + Code: 1000, // Normal closure + Reason: "Done", +}) +``` + +## Host Services + +Navidrome provides several host services that plugins can use to interact with external systems and access functionality. Plugins must declare permissions for each service they want to use in their `manifest.json`. + +### HTTP Service + +The HTTP service allows plugins to make HTTP requests to external APIs and services. To use this service, declare the `http` permission in your manifest. + +#### Basic Usage + +```json +{ + "permissions": { + "http": { + "reason": "To fetch artist metadata from external music APIs" + } + } +} +``` + +#### Granular Permissions + +For enhanced security, you can specify granular HTTP permissions that restrict which URLs and HTTP methods your plugin can access: + +```json +{ + "permissions": { + "http": { + "reason": "To fetch album reviews from AllMusic and artist data from MusicBrainz", + "allowedUrls": { + "https://api.allmusic.com": ["GET", "POST"], + "https://*.musicbrainz.org": ["GET"], + "https://coverartarchive.org": ["GET"], + "*": ["GET"] + }, + "allowLocalNetwork": false + } + } +} +``` + +**Permission Fields:** + +- `reason` (required): Clear explanation of why HTTP access is needed +- `allowedUrls` (required): Map of URL patterns to allowed HTTP methods + + - Must contain at least one URL pattern + - For unrestricted access, use: `{"*": ["*"]}` + - Keys can be exact URLs, wildcard patterns, or `*` for any URL + - Values are arrays of HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, or `*` for any method + - **Important**: Redirect destinations must also be included in this list. If a URL redirects to another URL not in `allowedUrls`, the redirect will be blocked. + +- `allowLocalNetwork` (optional, default: `false`): Whether to allow requests to localhost/private IPs + +**URL Pattern Matching:** + +- Exact URLs: `https://api.example.com` +- Wildcard subdomains: `https://*.example.com` (matches any subdomain) +- Wildcard paths: `https://example.com/api/*` (matches any path under /api/) +- Global wildcard: `*` (matches any URL - use with caution) + +**Examples:** + +```json +// Allow only GET requests to specific APIs +{ + "allowedUrls": { + "https://api.last.fm": ["GET"], + "https://ws.audioscrobbler.com": ["GET"] + } +} + +// Allow any method to a trusted domain, GET everywhere else +{ + "allowedUrls": { + "https://my-trusted-api.com": ["*"], + "*": ["GET"] + } +} + +// Handle redirects by including redirect destinations +{ + "allowedUrls": { + "https://short.ly/api123": ["GET"], // Original URL + "https://api.actual-service.com": ["GET"] // Redirect destination + } +} + +// Strict permissions for a secure plugin (blocks redirects by not including redirect destinations) +{ + "allowedUrls": { + "https://api.musicbrainz.org/ws/2": ["GET"] + }, + "allowLocalNetwork": false +} +``` + +#### Security Considerations + +The HTTP service implements several security features: + +1. **Local Network Protection**: By default, requests to localhost and private IP ranges are blocked +2. **URL Filtering**: Only URLs matching `allowedUrls` patterns are allowed +3. **Method Restrictions**: HTTP methods are validated against the allowed list for each URL pattern +4. **Redirect Security**: + - Redirect destinations must also match `allowedUrls` patterns and methods + - Maximum of 5 redirects per request to prevent redirect loops + - To block all redirects, simply don't include any redirect destinations in `allowedUrls` + +**Private IP Ranges Blocked (when `allowLocalNetwork: false`):** + +- IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16` +- IPv6: `::1`, `fe80::/10`, `fc00::/7` +- Hostnames: `localhost` + +#### Making HTTP Requests + +```go +import "github.com/navidrome/navidrome/plugins/host/http" + +// GET request +resp, err := httpClient.Get(ctx, &http.HttpRequest{ + Url: "https://api.example.com/data", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "User-Agent": "MyPlugin/1.0", + }, + TimeoutMs: 5000, +}) + +// POST request with body +resp, err := httpClient.Post(ctx, &http.HttpRequest{ + Url: "https://api.example.com/submit", + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: []byte(`{"key": "value"}`), + TimeoutMs: 10000, +}) + +// Handle response +if err != nil { + return &api.Response{Error: "HTTP request failed: " + err.Error()}, nil +} + +if resp.Error != "" { + return &api.Response{Error: "HTTP error: " + resp.Error}, nil +} + +if resp.Status != 200 { + return &api.Response{Error: fmt.Sprintf("HTTP %d: %s", resp.Status, string(resp.Body))}, nil +} + +// Use response data +data := resp.Body +headers := resp.Headers +``` + +### Other Host Services + +#### Config Service + +Access plugin-specific configuration: + +```json +{ + "permissions": { + "config": { + "reason": "To read API keys and service endpoints from plugin configuration" + } + } +} +``` + +#### Cache Service + +Store and retrieve data to improve performance: + +```json +{ + "permissions": { + "cache": { + "reason": "To cache API responses and reduce external service calls" + } + } +} +``` + +#### Scheduler Service + +Schedule recurring or one-time tasks: + +```json +{ + "permissions": { + "scheduler": { + "reason": "To schedule periodic metadata refresh and cleanup tasks" + } + } +} +``` + +#### WebSocket Service + +Connect to WebSocket endpoints: + +```json +{ + "permissions": { + "websocket": { + "reason": "To connect to real-time music service APIs for live data", + "allowedUrls": [ + "wss://api.musicservice.com/ws", + "wss://realtime.example.com" + ], + "allowLocalNetwork": false + } + } +} +``` + +#### Artwork Service + +Generate public URLs for artwork: + +```json +{ + "permissions": { + "artwork": { + "reason": "To generate public URLs for album and artist images" + } + } +} +``` + +### Error Handling + +Plugins should use the standard error values (`plugin:not_found`, `plugin:not_implemented`) to indicate resource-not-found and unimplemented-method scenarios. All other errors will be propagated directly to the caller. Ensure your capability methods return errors via the response message `error` fields rather than panicking or relying on transport errors. + +## Plugin Lifecycle and Statelessness + +**Important**: Navidrome plugins are stateless. Each method call creates a new plugin instance which is destroyed afterward. This has several important implications: + +1. **No in-memory persistence**: Plugins cannot store state between method calls in memory +2. **Each call is isolated**: Variables, configurations, and runtime state don't persist between calls +3. **No shared resources**: Each plugin instance has its own memory space + +This stateless design is crucial for security and stability: + +- Memory leaks in one call won't affect subsequent operations +- A crashed plugin instance won't bring down the entire system +- Resource usage is more predictable and contained + +When developing plugins, keep these guidelines in mind: + +- Don't try to cache data in memory between calls +- Don't store authentication tokens or session data in variables +- If persistence is needed, use external storage or the host's HTTP interface +- Performance optimizations should focus on efficient per-call execution + +### Using Plugin Configuration + +Since plugins are stateless, you can use the `LifecycleManagement` interface to read configuration when your plugin is loaded and perform any necessary setup: + +```go +func (p *myPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + // Access plugin configuration + apiKey := req.Config["api_key"] + if apiKey == "" { + return &api.InitResponse{Error: "Missing API key in configuration"}, nil + } + + // Validate configuration + serverURL := req.Config["server_url"] + if serverURL == "" { + serverURL = "https://default-api.example.com" // Use default if not specified + } + + // Perform initialization tasks (e.g., validate API key) + httpClient := &http.HttpServiceClient{} + resp, err := httpClient.Get(ctx, &http.HttpRequest{ + Url: serverURL + "/validate?key=" + apiKey, + }) + if err != nil { + return &api.InitResponse{Error: "Failed to validate API key: " + err.Error()}, nil + } + + if resp.StatusCode != 200 { + return &api.InitResponse{Error: "Invalid API key"}, nil + } + + return &api.InitResponse{}, nil +} +``` + +Remember, the `OnInit` method is called only once when the plugin is loaded. It cannot store any state that needs to persist between method calls. It's primarily useful for: + +1. Validating required configuration +2. Checking API credentials +3. Verifying connectivity to external services +4. Initializing any external resources + +## Caching + +The plugin system implements a compilation cache to improve performance: + +1. Compiled WASM modules are cached in `[CacheFolder]/plugins` +2. This reduces startup time for plugins that have already been compiled +3. The cache has a automatic cleanup mechanism to remove old modules. + - when the cache folder exceeds `Plugins.CacheSize` (default 100MB), + the oldest modules are removed + +### WASM Loading Optimization + +To improve performance during plugin instance creation, the system implements an optimization that avoids repeated file reads and compilation: + +1. **Precompilation**: During plugin discovery, WASM files are read and compiled in the background, with both the MD5 hash of the file bytes and compiled modules cached in memory. + +2. **Optimized Runtime**: After precompilation completes, plugins use an `optimizedRuntime` wrapper that overrides `CompileModule` to detect when the same WASM bytes are being compiled by comparing MD5 hashes. + +3. **Cache Hit**: When the generated plugin code calls `os.ReadFile()` and `CompileModule()`, the optimization calculates the MD5 hash of the incoming bytes and compares it with the cached hash. If they match, it returns the pre-compiled module directly. + +4. **Performance Benefit**: This eliminates repeated compilation while using minimal memory (16 bytes per plugin for the MD5 hash vs potentially MB of WASM bytes), significantly improving plugin instance creation speed while maintaining full compatibility with the generated API code. + +5. **Memory Efficiency**: By storing only MD5 hashes instead of full WASM bytes, the optimization scales efficiently regardless of plugin size or count. + +The optimization is transparent to plugin developers and automatically activates when plugins are successfully precompiled. + +## Best Practices + +1. **Resource Management**: + + - The host handles HTTP response cleanup, so no need to close response objects + - Keep plugin instances lightweight as they are created and destroyed frequently + +2. **Error Handling**: + + - Use the standard error types when appropriate + - Return descriptive error messages for debugging + - Custom errors are supported and will be propagated to the caller + +3. **Performance**: + + - Remember plugins are stateless, so don't rely on local variables for caching. Use the CacheService for caching data. + - Use efficient algorithms that work well in single-call scenarios + +4. **Security**: + - Only request permissions you actually need (see [Plugin Permission System](#plugin-permission-system)) + - Validate inputs to prevent injection attacks + - Don't store sensitive credentials in the plugin code + - Use configuration for API keys and sensitive data + +## Limitations + +1. WASM plugins have limited access to system resources +2. Plugin compilation has an initial overhead on first load, as it needs to be compiled to WebAssembly + - Subsequent calls are faster due to caching +3. New plugin capabilities types require changes to the core codebase +4. Stateless nature prevents certain optimizations + +## Troubleshooting + +1. **Plugin not detected**: + + - Ensure `plugin.wasm` and `manifest.json` exist in the plugin directory + - Check that the manifest contains valid capabilities names + - Verify the manifest schema is valid (see [Plugin Permission System](#plugin-permission-system)) + +2. **Permission errors**: + + - **"function not exported in module env"**: Plugin trying to use a service without proper permission + - Check that required permissions are declared in `manifest.json` + - See [Troubleshooting Permissions](#troubleshooting-permissions) for detailed guidance + +3. **Compilation errors**: + + - Check logs for WASM compilation errors + - Verify the plugin is compatible with the current API version + +4. **Runtime errors**: + - Look for error messages in the Navidrome logs + - Add debug logging to your plugin + - Check if the error is permission-related before debugging plugin logic diff --git a/plugins/adapter_media_agent.go b/plugins/adapter_media_agent.go new file mode 100644 index 000000000..43fc0e030 --- /dev/null +++ b/plugins/adapter_media_agent.go @@ -0,0 +1,166 @@ +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 *Manager, 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{ + wasmBasePlugin: newWasmBasePlugin[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 { + *wasmBasePlugin[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) { + return callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*agents.AlbumInfo, error) { + res, err := 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) { + return callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) { + res, err := 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) { + return callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (string, error) { + res, err := 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) { + return callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (string, error) { + res, err := 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) { + return callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (string, error) { + res, err := 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) { + return callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) ([]agents.Artist, error) { + resp, err := 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) { + return callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) ([]agents.ExternalImage, error) { + res, err := inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid}) + if err != nil { + return nil, w.mapError(err) + } + return convertExternalImages(res.Images), nil + }) +} + +func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { + return callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) ([]agents.Song, error) { + resp, err := 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 +} diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go new file mode 100644 index 000000000..709fd62cd --- /dev/null +++ b/plugins/adapter_media_agent_test.go @@ -0,0 +1,220 @@ +package plugins + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "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 *Manager + + BeforeEach(func() { + ctx = GinkgoT().Context() + + // Ensure plugins folder is set to testdata + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Folder = testDataDir + + mgr = createManager(nil, nil) + mgr.ScanPlugins() + }) + + 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()) + }) + }) +}) diff --git a/plugins/adapter_scheduler_callback.go b/plugins/adapter_scheduler_callback.go new file mode 100644 index 000000000..2fe94d613 --- /dev/null +++ b/plugins/adapter_scheduler_callback.go @@ -0,0 +1,35 @@ +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 *Manager, 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{ + wasmBasePlugin: newWasmBasePlugin[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 { + *wasmBasePlugin[api.SchedulerCallback, *api.SchedulerCallbackPlugin] +} diff --git a/plugins/adapter_scrobbler.go b/plugins/adapter_scrobbler.go new file mode 100644 index 000000000..b9c27901f --- /dev/null +++ b/plugins/adapter_scrobbler.go @@ -0,0 +1,154 @@ +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 *Manager, 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{ + wasmBasePlugin: newWasmBasePlugin[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 { + *wasmBasePlugin[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 + } + } + + result, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (bool, error) { + resp, err := inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{ + UserId: userId, + Username: username, + }) + if err != nil { + return false, err + } + if resp.Error != "" { + return false, nil + } + return resp.Authorized, nil + }) + return err == nil && result +} + +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 + } + } + + 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), + } + _, 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 + } + } + + track := &s.MediaFile + 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), + } + _, 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 +} diff --git a/plugins/adapter_websocket_callback.go b/plugins/adapter_websocket_callback.go new file mode 100644 index 000000000..c45ee342e --- /dev/null +++ b/plugins/adapter_websocket_callback.go @@ -0,0 +1,35 @@ +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 *Manager, 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{ + wasmBasePlugin: newWasmBasePlugin[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 { + *wasmBasePlugin[api.WebSocketCallback, *api.WebSocketCallbackPlugin] +} diff --git a/plugins/api/api.pb.go b/plugins/api/api.pb.go new file mode 100644 index 000000000..473598904 --- /dev/null +++ b/plugins/api/api.pb.go @@ -0,0 +1,1137 @@ +// 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" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ArtistMBIDRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *ArtistMBIDRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistMBIDRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistMBIDRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type ArtistMBIDResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Mbid string `protobuf:"bytes,1,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistMBIDResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistMBIDResponse) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistURLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistURLRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistURLRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistURLRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistURLRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistURLResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *ArtistURLResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistURLResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type ArtistBiographyRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistBiographyRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistBiographyRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistBiographyRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistBiographyRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistBiographyResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Biography string `protobuf:"bytes,1,opt,name=biography,proto3" json:"biography,omitempty"` +} + +func (x *ArtistBiographyResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistBiographyResponse) GetBiography() string { + if x != nil { + return x.Biography + } + return "" +} + +type ArtistSimilarRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` + Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *ArtistSimilarRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistSimilarRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistSimilarRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistSimilarRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *ArtistSimilarRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type Artist struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *Artist) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *Artist) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Artist) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistSimilarResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Artists []*Artist `protobuf:"bytes,1,rep,name=artists,proto3" json:"artists,omitempty"` +} + +func (x *ArtistSimilarResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistSimilarResponse) GetArtists() []*Artist { + if x != nil { + return x.Artists + } + return nil +} + +type ArtistImageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistImageRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistImageRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistImageRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistImageRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ExternalImage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` +} + +func (x *ExternalImage) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ExternalImage) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ExternalImage) GetSize() int32 { + if x != nil { + return x.Size + } + return 0 +} + +type ArtistImageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Images []*ExternalImage `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"` +} + +func (x *ArtistImageResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistImageResponse) GetImages() []*ExternalImage { + if x != nil { + return x.Images + } + return nil +} + +type ArtistTopSongsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ArtistName string `protobuf:"bytes,2,opt,name=artistName,proto3" json:"artistName,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` + Count int32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` +} + +func (x *ArtistTopSongsRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistTopSongsRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistTopSongsRequest) GetArtistName() string { + if x != nil { + return x.ArtistName + } + return "" +} + +func (x *ArtistTopSongsRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *ArtistTopSongsRequest) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +type Song struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *Song) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *Song) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Song) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistTopSongsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Songs []*Song `protobuf:"bytes,1,rep,name=songs,proto3" json:"songs,omitempty"` +} + +func (x *ArtistTopSongsResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistTopSongsResponse) GetSongs() []*Song { + if x != nil { + return x.Songs + } + return nil +} + +type AlbumInfoRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Artist string `protobuf:"bytes,2,opt,name=artist,proto3" json:"artist,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *AlbumInfoRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumInfoRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AlbumInfoRequest) GetArtist() string { + if x != nil { + return x.Artist + } + return "" +} + +func (x *AlbumInfoRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type AlbumInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *AlbumInfo) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AlbumInfo) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *AlbumInfo) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AlbumInfo) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type AlbumInfoResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Info *AlbumInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` +} + +func (x *AlbumInfoResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumInfoResponse) GetInfo() *AlbumInfo { + if x != nil { + return x.Info + } + return nil +} + +type AlbumImagesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Artist string `protobuf:"bytes,2,opt,name=artist,proto3" json:"artist,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *AlbumImagesRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumImagesRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AlbumImagesRequest) GetArtist() string { + if x != nil { + return x.Artist + } + return "" +} + +func (x *AlbumImagesRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type AlbumImagesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Images []*ExternalImage `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"` +} + +func (x *AlbumImagesResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumImagesResponse) GetImages() []*ExternalImage { + if x != nil { + return x.Images + } + return nil +} + +type ScrobblerIsAuthorizedRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` +} + +func (x *ScrobblerIsAuthorizedRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerIsAuthorizedRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ScrobblerIsAuthorizedRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +type ScrobblerIsAuthorizedResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Authorized bool `protobuf:"varint,1,opt,name=authorized,proto3" json:"authorized,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ScrobblerIsAuthorizedResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerIsAuthorizedResponse) GetAuthorized() bool { + if x != nil { + return x.Authorized + } + return false +} + +func (x *ScrobblerIsAuthorizedResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type TrackInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Album string `protobuf:"bytes,4,opt,name=album,proto3" json:"album,omitempty"` + AlbumMbid string `protobuf:"bytes,5,opt,name=album_mbid,json=albumMbid,proto3" json:"album_mbid,omitempty"` + Artists []*Artist `protobuf:"bytes,6,rep,name=artists,proto3" json:"artists,omitempty"` + AlbumArtists []*Artist `protobuf:"bytes,7,rep,name=album_artists,json=albumArtists,proto3" json:"album_artists,omitempty"` + Length int32 `protobuf:"varint,8,opt,name=length,proto3" json:"length,omitempty"` // seconds + Position int32 `protobuf:"varint,9,opt,name=position,proto3" json:"position,omitempty"` // seconds +} + +func (x *TrackInfo) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *TrackInfo) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TrackInfo) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *TrackInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TrackInfo) GetAlbum() string { + if x != nil { + return x.Album + } + return "" +} + +func (x *TrackInfo) GetAlbumMbid() string { + if x != nil { + return x.AlbumMbid + } + return "" +} + +func (x *TrackInfo) GetArtists() []*Artist { + if x != nil { + return x.Artists + } + return nil +} + +func (x *TrackInfo) GetAlbumArtists() []*Artist { + if x != nil { + return x.AlbumArtists + } + return nil +} + +func (x *TrackInfo) GetLength() int32 { + if x != nil { + return x.Length + } + return 0 +} + +func (x *TrackInfo) GetPosition() int32 { + if x != nil { + return x.Position + } + return 0 +} + +type ScrobblerNowPlayingRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Track *TrackInfo `protobuf:"bytes,3,opt,name=track,proto3" json:"track,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *ScrobblerNowPlayingRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerNowPlayingRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ScrobblerNowPlayingRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ScrobblerNowPlayingRequest) GetTrack() *TrackInfo { + if x != nil { + return x.Track + } + return nil +} + +func (x *ScrobblerNowPlayingRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type ScrobblerNowPlayingResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ScrobblerNowPlayingResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerNowPlayingResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ScrobblerScrobbleRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Track *TrackInfo `protobuf:"bytes,3,opt,name=track,proto3" json:"track,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *ScrobblerScrobbleRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerScrobbleRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ScrobblerScrobbleRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ScrobblerScrobbleRequest) GetTrack() *TrackInfo { + if x != nil { + return x.Track + } + return nil +} + +func (x *ScrobblerScrobbleRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type ScrobblerScrobbleResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ScrobblerScrobbleResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerScrobbleResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SchedulerCallbackRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the scheduled job that triggered this callback + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // The data passed when the job was scheduled + IsRecurring bool `protobuf:"varint,3,opt,name=is_recurring,json=isRecurring,proto3" json:"is_recurring,omitempty"` // Whether this is from a recurring schedule (cron job) +} + +func (x *SchedulerCallbackRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SchedulerCallbackRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +func (x *SchedulerCallbackRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *SchedulerCallbackRequest) GetIsRecurring() bool { + if x != nil { + return x.IsRecurring + } + return false +} + +type SchedulerCallbackResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // Error message if the callback failed +} + +func (x *SchedulerCallbackResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SchedulerCallbackResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type InitRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Empty for now + Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin +} + +func (x *InitRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *InitRequest) GetConfig() map[string]string { + if x != nil { + return x.Config + } + return nil +} + +type InitResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // Error message if initialization failed +} + +func (x *InitResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *InitResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type OnTextMessageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *OnTextMessageRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnTextMessageRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnTextMessageRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type OnTextMessageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnTextMessageResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type OnBinaryMessageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *OnBinaryMessageRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnBinaryMessageRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnBinaryMessageRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type OnBinaryMessageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnBinaryMessageResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type OnErrorRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *OnErrorRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnErrorRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnErrorRequest) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type OnErrorResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnErrorResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type OnCloseRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *OnCloseRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnCloseRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnCloseRequest) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *OnCloseRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type OnCloseResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnCloseResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +// go:plugin type=plugin version=1 +type MetadataAgent interface { + // Artist metadata methods + GetArtistMBID(context.Context, *ArtistMBIDRequest) (*ArtistMBIDResponse, error) + GetArtistURL(context.Context, *ArtistURLRequest) (*ArtistURLResponse, error) + GetArtistBiography(context.Context, *ArtistBiographyRequest) (*ArtistBiographyResponse, error) + GetSimilarArtists(context.Context, *ArtistSimilarRequest) (*ArtistSimilarResponse, error) + GetArtistImages(context.Context, *ArtistImageRequest) (*ArtistImageResponse, error) + GetArtistTopSongs(context.Context, *ArtistTopSongsRequest) (*ArtistTopSongsResponse, error) + // Album metadata methods + GetAlbumInfo(context.Context, *AlbumInfoRequest) (*AlbumInfoResponse, error) + GetAlbumImages(context.Context, *AlbumImagesRequest) (*AlbumImagesResponse, error) +} + +// go:plugin type=plugin version=1 +type Scrobbler interface { + IsAuthorized(context.Context, *ScrobblerIsAuthorizedRequest) (*ScrobblerIsAuthorizedResponse, error) + NowPlaying(context.Context, *ScrobblerNowPlayingRequest) (*ScrobblerNowPlayingResponse, error) + Scrobble(context.Context, *ScrobblerScrobbleRequest) (*ScrobblerScrobbleResponse, error) +} + +// go:plugin type=plugin version=1 +type SchedulerCallback interface { + OnSchedulerCallback(context.Context, *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) +} + +// go:plugin type=plugin version=1 +type LifecycleManagement interface { + OnInit(context.Context, *InitRequest) (*InitResponse, error) +} + +// go:plugin type=plugin version=1 +type WebSocketCallback interface { + // Called when a text message is received + OnTextMessage(context.Context, *OnTextMessageRequest) (*OnTextMessageResponse, error) + // Called when a binary message is received + OnBinaryMessage(context.Context, *OnBinaryMessageRequest) (*OnBinaryMessageResponse, error) + // Called when an error occurs + OnError(context.Context, *OnErrorRequest) (*OnErrorResponse, error) + // Called when the connection is closed + OnClose(context.Context, *OnCloseRequest) (*OnCloseResponse, error) +} diff --git a/plugins/api/api.proto b/plugins/api/api.proto new file mode 100644 index 000000000..c451a82fc --- /dev/null +++ b/plugins/api/api.proto @@ -0,0 +1,247 @@ +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 { + // Empty for now + map 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 {} \ No newline at end of file diff --git a/plugins/api/api_host.pb.go b/plugins/api/api_host.pb.go new file mode 100644 index 000000000..55e648c6c --- /dev/null +++ b/plugins/api/api_host.pb.go @@ -0,0 +1,1688 @@ +//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" + errors "errors" + fmt "fmt" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" + sys "github.com/tetratelabs/wazero/sys" + os "os" +) + +const MetadataAgentPluginAPIVersion = 1 + +type MetadataAgentPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewMetadataAgentPlugin(ctx context.Context, opts ...wazeroConfigOption) (*MetadataAgentPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &MetadataAgentPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type metadataAgent interface { + Close(ctx context.Context) error + MetadataAgent +} + +func (p *MetadataAgentPlugin) Load(ctx context.Context, pluginPath string) (metadataAgent, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("metadata_agent_api_version") + if apiVersion == nil { + return nil, errors.New("metadata_agent_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid metadata_agent_api_version signature") + } + if results[0] != MetadataAgentPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", MetadataAgentPluginAPIVersion, results[0]) + } + + getartistmbid := module.ExportedFunction("metadata_agent_get_artist_mbid") + if getartistmbid == nil { + return nil, errors.New("metadata_agent_get_artist_mbid is not exported") + } + getartisturl := module.ExportedFunction("metadata_agent_get_artist_url") + if getartisturl == nil { + return nil, errors.New("metadata_agent_get_artist_url is not exported") + } + getartistbiography := module.ExportedFunction("metadata_agent_get_artist_biography") + if getartistbiography == nil { + return nil, errors.New("metadata_agent_get_artist_biography is not exported") + } + getsimilarartists := module.ExportedFunction("metadata_agent_get_similar_artists") + if getsimilarartists == nil { + return nil, errors.New("metadata_agent_get_similar_artists is not exported") + } + getartistimages := module.ExportedFunction("metadata_agent_get_artist_images") + if getartistimages == nil { + return nil, errors.New("metadata_agent_get_artist_images is not exported") + } + getartisttopsongs := module.ExportedFunction("metadata_agent_get_artist_top_songs") + if getartisttopsongs == nil { + return nil, errors.New("metadata_agent_get_artist_top_songs is not exported") + } + getalbuminfo := module.ExportedFunction("metadata_agent_get_album_info") + if getalbuminfo == nil { + return nil, errors.New("metadata_agent_get_album_info is not exported") + } + getalbumimages := module.ExportedFunction("metadata_agent_get_album_images") + if getalbumimages == nil { + return nil, errors.New("metadata_agent_get_album_images is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &metadataAgentPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + getartistmbid: getartistmbid, + getartisturl: getartisturl, + getartistbiography: getartistbiography, + getsimilarartists: getsimilarartists, + getartistimages: getartistimages, + getartisttopsongs: getartisttopsongs, + getalbuminfo: getalbuminfo, + getalbumimages: getalbumimages, + }, nil +} + +func (p *metadataAgentPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type metadataAgentPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + getartistmbid api.Function + getartisturl api.Function + getartistbiography api.Function + getsimilarartists api.Function + getartistimages api.Function + getartisttopsongs api.Function + getalbuminfo api.Function + getalbumimages api.Function +} + +func (p *metadataAgentPlugin) GetArtistMBID(ctx context.Context, request *ArtistMBIDRequest) (*ArtistMBIDResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartistmbid.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistMBIDResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistURL(ctx context.Context, request *ArtistURLRequest) (*ArtistURLResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartisturl.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistURLResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistBiography(ctx context.Context, request *ArtistBiographyRequest) (*ArtistBiographyResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartistbiography.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistBiographyResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetSimilarArtists(ctx context.Context, request *ArtistSimilarRequest) (*ArtistSimilarResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getsimilarartists.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistSimilarResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistImages(ctx context.Context, request *ArtistImageRequest) (*ArtistImageResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartistimages.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistImageResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistTopSongs(ctx context.Context, request *ArtistTopSongsRequest) (*ArtistTopSongsResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartisttopsongs.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistTopSongsResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetAlbumInfo(ctx context.Context, request *AlbumInfoRequest) (*AlbumInfoResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getalbuminfo.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(AlbumInfoResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetAlbumImages(ctx context.Context, request *AlbumImagesRequest) (*AlbumImagesResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getalbumimages.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(AlbumImagesResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const ScrobblerPluginAPIVersion = 1 + +type ScrobblerPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewScrobblerPlugin(ctx context.Context, opts ...wazeroConfigOption) (*ScrobblerPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &ScrobblerPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type scrobbler interface { + Close(ctx context.Context) error + Scrobbler +} + +func (p *ScrobblerPlugin) Load(ctx context.Context, pluginPath string) (scrobbler, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("scrobbler_api_version") + if apiVersion == nil { + return nil, errors.New("scrobbler_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid scrobbler_api_version signature") + } + if results[0] != ScrobblerPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", ScrobblerPluginAPIVersion, results[0]) + } + + isauthorized := module.ExportedFunction("scrobbler_is_authorized") + if isauthorized == nil { + return nil, errors.New("scrobbler_is_authorized is not exported") + } + nowplaying := module.ExportedFunction("scrobbler_now_playing") + if nowplaying == nil { + return nil, errors.New("scrobbler_now_playing is not exported") + } + scrobble := module.ExportedFunction("scrobbler_scrobble") + if scrobble == nil { + return nil, errors.New("scrobbler_scrobble is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &scrobblerPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + isauthorized: isauthorized, + nowplaying: nowplaying, + scrobble: scrobble, + }, nil +} + +func (p *scrobblerPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type scrobblerPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + isauthorized api.Function + nowplaying api.Function + scrobble api.Function +} + +func (p *scrobblerPlugin) IsAuthorized(ctx context.Context, request *ScrobblerIsAuthorizedRequest) (*ScrobblerIsAuthorizedResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.isauthorized.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ScrobblerIsAuthorizedResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *scrobblerPlugin) NowPlaying(ctx context.Context, request *ScrobblerNowPlayingRequest) (*ScrobblerNowPlayingResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.nowplaying.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ScrobblerNowPlayingResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *scrobblerPlugin) Scrobble(ctx context.Context, request *ScrobblerScrobbleRequest) (*ScrobblerScrobbleResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.scrobble.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ScrobblerScrobbleResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const SchedulerCallbackPluginAPIVersion = 1 + +type SchedulerCallbackPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewSchedulerCallbackPlugin(ctx context.Context, opts ...wazeroConfigOption) (*SchedulerCallbackPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &SchedulerCallbackPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type schedulerCallback interface { + Close(ctx context.Context) error + SchedulerCallback +} + +func (p *SchedulerCallbackPlugin) Load(ctx context.Context, pluginPath string) (schedulerCallback, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("scheduler_callback_api_version") + if apiVersion == nil { + return nil, errors.New("scheduler_callback_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid scheduler_callback_api_version signature") + } + if results[0] != SchedulerCallbackPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", SchedulerCallbackPluginAPIVersion, results[0]) + } + + onschedulercallback := module.ExportedFunction("scheduler_callback_on_scheduler_callback") + if onschedulercallback == nil { + return nil, errors.New("scheduler_callback_on_scheduler_callback is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &schedulerCallbackPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + onschedulercallback: onschedulercallback, + }, nil +} + +func (p *schedulerCallbackPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type schedulerCallbackPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + onschedulercallback api.Function +} + +func (p *schedulerCallbackPlugin) OnSchedulerCallback(ctx context.Context, request *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onschedulercallback.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(SchedulerCallbackResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const LifecycleManagementPluginAPIVersion = 1 + +type LifecycleManagementPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewLifecycleManagementPlugin(ctx context.Context, opts ...wazeroConfigOption) (*LifecycleManagementPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &LifecycleManagementPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type lifecycleManagement interface { + Close(ctx context.Context) error + LifecycleManagement +} + +func (p *LifecycleManagementPlugin) Load(ctx context.Context, pluginPath string) (lifecycleManagement, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("lifecycle_management_api_version") + if apiVersion == nil { + return nil, errors.New("lifecycle_management_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid lifecycle_management_api_version signature") + } + if results[0] != LifecycleManagementPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", LifecycleManagementPluginAPIVersion, results[0]) + } + + oninit := module.ExportedFunction("lifecycle_management_on_init") + if oninit == nil { + return nil, errors.New("lifecycle_management_on_init is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &lifecycleManagementPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + oninit: oninit, + }, nil +} + +func (p *lifecycleManagementPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type lifecycleManagementPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + oninit api.Function +} + +func (p *lifecycleManagementPlugin) OnInit(ctx context.Context, request *InitRequest) (*InitResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.oninit.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(InitResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const WebSocketCallbackPluginAPIVersion = 1 + +type WebSocketCallbackPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewWebSocketCallbackPlugin(ctx context.Context, opts ...wazeroConfigOption) (*WebSocketCallbackPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &WebSocketCallbackPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type webSocketCallback interface { + Close(ctx context.Context) error + WebSocketCallback +} + +func (p *WebSocketCallbackPlugin) Load(ctx context.Context, pluginPath string) (webSocketCallback, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("web_socket_callback_api_version") + if apiVersion == nil { + return nil, errors.New("web_socket_callback_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid web_socket_callback_api_version signature") + } + if results[0] != WebSocketCallbackPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", WebSocketCallbackPluginAPIVersion, results[0]) + } + + ontextmessage := module.ExportedFunction("web_socket_callback_on_text_message") + if ontextmessage == nil { + return nil, errors.New("web_socket_callback_on_text_message is not exported") + } + onbinarymessage := module.ExportedFunction("web_socket_callback_on_binary_message") + if onbinarymessage == nil { + return nil, errors.New("web_socket_callback_on_binary_message is not exported") + } + onerror := module.ExportedFunction("web_socket_callback_on_error") + if onerror == nil { + return nil, errors.New("web_socket_callback_on_error is not exported") + } + onclose := module.ExportedFunction("web_socket_callback_on_close") + if onclose == nil { + return nil, errors.New("web_socket_callback_on_close is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &webSocketCallbackPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + ontextmessage: ontextmessage, + onbinarymessage: onbinarymessage, + onerror: onerror, + onclose: onclose, + }, nil +} + +func (p *webSocketCallbackPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type webSocketCallbackPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + ontextmessage api.Function + onbinarymessage api.Function + onerror api.Function + onclose api.Function +} + +func (p *webSocketCallbackPlugin) OnTextMessage(ctx context.Context, request *OnTextMessageRequest) (*OnTextMessageResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.ontextmessage.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnTextMessageResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *webSocketCallbackPlugin) OnBinaryMessage(ctx context.Context, request *OnBinaryMessageRequest) (*OnBinaryMessageResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onbinarymessage.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnBinaryMessageResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *webSocketCallbackPlugin) OnError(ctx context.Context, request *OnErrorRequest) (*OnErrorResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onerror.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnErrorResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *webSocketCallbackPlugin) OnClose(ctx context.Context, request *OnCloseRequest) (*OnCloseResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onclose.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnCloseResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} diff --git a/plugins/api/api_options.pb.go b/plugins/api/api_options.pb.go new file mode 100644 index 000000000..430bf0a5c --- /dev/null +++ b/plugins/api/api_options.pb.go @@ -0,0 +1,47 @@ +//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 + } +} diff --git a/plugins/api/api_plugin.pb.go b/plugins/api/api_plugin.pb.go new file mode 100644 index 000000000..0a022be9b --- /dev/null +++ b/plugins/api/api_plugin.pb.go @@ -0,0 +1,487 @@ +//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) +} diff --git a/plugins/api/api_plugin_dev.go b/plugins/api/api_plugin_dev.go new file mode 100644 index 000000000..ed5a064b2 --- /dev/null +++ b/plugins/api/api_plugin_dev.go @@ -0,0 +1,34 @@ +//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") +} diff --git a/plugins/api/api_plugin_dev_named_registry.go b/plugins/api/api_plugin_dev_named_registry.go new file mode 100644 index 000000000..05421ad73 --- /dev/null +++ b/plugins/api/api_plugin_dev_named_registry.go @@ -0,0 +1,90 @@ +//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) +} diff --git a/plugins/api/api_vtproto.pb.go b/plugins/api/api_vtproto.pb.go new file mode 100644 index 000000000..11caa1946 --- /dev/null +++ b/plugins/api/api_vtproto.pb.go @@ -0,0 +1,7315 @@ +// 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 ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *ArtistMBIDRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistMBIDRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistMBIDRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistMBIDResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistMBIDResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistMBIDResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistURLRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistURLRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistURLRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistURLResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistURLResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistURLResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistBiographyRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistBiographyRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistBiographyRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistBiographyResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistBiographyResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistBiographyResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Biography) > 0 { + i -= len(m.Biography) + copy(dAtA[i:], m.Biography) + i = encodeVarint(dAtA, i, uint64(len(m.Biography))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistSimilarRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistSimilarRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistSimilarRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Limit != 0 { + i = encodeVarint(dAtA, i, uint64(m.Limit)) + i-- + dAtA[i] = 0x20 + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Artist) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Artist) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *Artist) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistSimilarResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistSimilarResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistSimilarResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Artists) > 0 { + for iNdEx := len(m.Artists) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Artists[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *ArtistImageRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistImageRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistImageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ExternalImage) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ExternalImage) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ExternalImage) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Size != 0 { + i = encodeVarint(dAtA, i, uint64(m.Size)) + i-- + dAtA[i] = 0x10 + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistImageResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistImageResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistImageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Images) > 0 { + for iNdEx := len(m.Images) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Images[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *ArtistTopSongsRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistTopSongsRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistTopSongsRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Count != 0 { + i = encodeVarint(dAtA, i, uint64(m.Count)) + i-- + dAtA[i] = 0x20 + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.ArtistName) > 0 { + i -= len(m.ArtistName) + copy(dAtA[i:], m.ArtistName) + i = encodeVarint(dAtA, i, uint64(len(m.ArtistName))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Song) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Song) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *Song) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistTopSongsResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistTopSongsResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistTopSongsResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Songs) > 0 { + for iNdEx := len(m.Songs) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Songs[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *AlbumInfoRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumInfoRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumInfoRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Artist) > 0 { + i -= len(m.Artist) + copy(dAtA[i:], m.Artist) + i = encodeVarint(dAtA, i, uint64(len(m.Artist))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumInfo) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumInfo) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumInfo) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0x22 + } + if len(m.Description) > 0 { + i -= len(m.Description) + copy(dAtA[i:], m.Description) + i = encodeVarint(dAtA, i, uint64(len(m.Description))) + i-- + dAtA[i] = 0x1a + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumInfoResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumInfoResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumInfoResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Info != nil { + size, err := m.Info.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumImagesRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumImagesRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumImagesRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Artist) > 0 { + i -= len(m.Artist) + copy(dAtA[i:], m.Artist) + i = encodeVarint(dAtA, i, uint64(len(m.Artist))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumImagesResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumImagesResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumImagesResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Images) > 0 { + for iNdEx := len(m.Images) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Images[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerIsAuthorizedRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerIsAuthorizedRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerIsAuthorizedRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Username) > 0 { + i -= len(m.Username) + copy(dAtA[i:], m.Username) + i = encodeVarint(dAtA, i, uint64(len(m.Username))) + i-- + dAtA[i] = 0x12 + } + if len(m.UserId) > 0 { + i -= len(m.UserId) + copy(dAtA[i:], m.UserId) + i = encodeVarint(dAtA, i, uint64(len(m.UserId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerIsAuthorizedResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerIsAuthorizedResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerIsAuthorizedResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if m.Authorized { + i-- + if m.Authorized { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *TrackInfo) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TrackInfo) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TrackInfo) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Position != 0 { + i = encodeVarint(dAtA, i, uint64(m.Position)) + i-- + dAtA[i] = 0x48 + } + if m.Length != 0 { + i = encodeVarint(dAtA, i, uint64(m.Length)) + i-- + dAtA[i] = 0x40 + } + if len(m.AlbumArtists) > 0 { + for iNdEx := len(m.AlbumArtists) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.AlbumArtists[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x3a + } + } + if len(m.Artists) > 0 { + for iNdEx := len(m.Artists) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Artists[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x32 + } + } + if len(m.AlbumMbid) > 0 { + i -= len(m.AlbumMbid) + copy(dAtA[i:], m.AlbumMbid) + i = encodeVarint(dAtA, i, uint64(len(m.AlbumMbid))) + i-- + dAtA[i] = 0x2a + } + if len(m.Album) > 0 { + i -= len(m.Album) + copy(dAtA[i:], m.Album) + i = encodeVarint(dAtA, i, uint64(len(m.Album))) + i-- + dAtA[i] = 0x22 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x1a + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerNowPlayingRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerNowPlayingRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerNowPlayingRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Timestamp != 0 { + i = encodeVarint(dAtA, i, uint64(m.Timestamp)) + i-- + dAtA[i] = 0x20 + } + if m.Track != nil { + size, err := m.Track.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } + if len(m.Username) > 0 { + i -= len(m.Username) + copy(dAtA[i:], m.Username) + i = encodeVarint(dAtA, i, uint64(len(m.Username))) + i-- + dAtA[i] = 0x12 + } + if len(m.UserId) > 0 { + i -= len(m.UserId) + copy(dAtA[i:], m.UserId) + i = encodeVarint(dAtA, i, uint64(len(m.UserId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerNowPlayingResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerNowPlayingResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerNowPlayingResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerScrobbleRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerScrobbleRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerScrobbleRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Timestamp != 0 { + i = encodeVarint(dAtA, i, uint64(m.Timestamp)) + i-- + dAtA[i] = 0x20 + } + if m.Track != nil { + size, err := m.Track.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } + if len(m.Username) > 0 { + i -= len(m.Username) + copy(dAtA[i:], m.Username) + i = encodeVarint(dAtA, i, uint64(len(m.Username))) + i-- + dAtA[i] = 0x12 + } + if len(m.UserId) > 0 { + i -= len(m.UserId) + copy(dAtA[i:], m.UserId) + i = encodeVarint(dAtA, i, uint64(len(m.UserId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerScrobbleResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerScrobbleResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerScrobbleResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SchedulerCallbackRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SchedulerCallbackRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SchedulerCallbackRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.IsRecurring { + i-- + if m.IsRecurring { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x18 + } + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarint(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x12 + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SchedulerCallbackResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SchedulerCallbackResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SchedulerCallbackResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *InitRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *InitRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *InitRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Config) > 0 { + for k := range m.Config { + v := m.Config[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *InitResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *InitResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *InitResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnTextMessageRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnTextMessageRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnTextMessageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Message) > 0 { + i -= len(m.Message) + copy(dAtA[i:], m.Message) + i = encodeVarint(dAtA, i, uint64(len(m.Message))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnTextMessageResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnTextMessageResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnTextMessageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *OnBinaryMessageRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnBinaryMessageRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnBinaryMessageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Data) > 0 { + i -= len(m.Data) + copy(dAtA[i:], m.Data) + i = encodeVarint(dAtA, i, uint64(len(m.Data))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnBinaryMessageResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnBinaryMessageResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnBinaryMessageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *OnErrorRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnErrorRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnErrorRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnErrorResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnErrorResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnErrorResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *OnCloseRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnCloseRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnCloseRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Reason) > 0 { + i -= len(m.Reason) + copy(dAtA[i:], m.Reason) + i = encodeVarint(dAtA, i, uint64(len(m.Reason))) + i-- + dAtA[i] = 0x1a + } + if m.Code != 0 { + i = encodeVarint(dAtA, i, uint64(m.Code)) + i-- + dAtA[i] = 0x10 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnCloseResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnCloseResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnCloseResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ArtistMBIDRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistMBIDResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistURLRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistURLResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistBiographyRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistBiographyResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Biography) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistSimilarRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Limit != 0 { + n += 1 + sov(uint64(m.Limit)) + } + n += len(m.unknownFields) + return n +} + +func (m *Artist) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistSimilarResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Artists) > 0 { + for _, e := range m.Artists { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistImageRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ExternalImage) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Size != 0 { + n += 1 + sov(uint64(m.Size)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistImageResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Images) > 0 { + for _, e := range m.Images { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistTopSongsRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.ArtistName) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Count != 0 { + n += 1 + sov(uint64(m.Count)) + } + n += len(m.unknownFields) + return n +} + +func (m *Song) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistTopSongsResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Songs) > 0 { + for _, e := range m.Songs { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumInfoRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Artist) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumInfo) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Description) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumInfoResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Info != nil { + l = m.Info.SizeVT() + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumImagesRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Artist) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumImagesResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Images) > 0 { + for _, e := range m.Images { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerIsAuthorizedRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.UserId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Username) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerIsAuthorizedResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Authorized { + n += 2 + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *TrackInfo) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Album) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.AlbumMbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Artists) > 0 { + for _, e := range m.Artists { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + if len(m.AlbumArtists) > 0 { + for _, e := range m.AlbumArtists { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + if m.Length != 0 { + n += 1 + sov(uint64(m.Length)) + } + if m.Position != 0 { + n += 1 + sov(uint64(m.Position)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerNowPlayingRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.UserId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Username) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Track != nil { + l = m.Track.SizeVT() + n += 1 + l + sov(uint64(l)) + } + if m.Timestamp != 0 { + n += 1 + sov(uint64(m.Timestamp)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerNowPlayingResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerScrobbleRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.UserId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Username) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Track != nil { + l = m.Track.SizeVT() + n += 1 + l + sov(uint64(l)) + } + if m.Timestamp != 0 { + n += 1 + sov(uint64(m.Timestamp)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerScrobbleResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SchedulerCallbackRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.IsRecurring { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func (m *SchedulerCallbackResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *InitRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Config) > 0 { + for k, v := range m.Config { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *InitResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnTextMessageRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Message) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnTextMessageResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *OnBinaryMessageRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Data) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnBinaryMessageResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *OnErrorRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnErrorResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *OnCloseRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Code != 0 { + n += 1 + sov(uint64(m.Code)) + } + l = len(m.Reason) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnCloseResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ArtistMBIDRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistMBIDRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistMBIDRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistMBIDResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistMBIDResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistMBIDResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistURLRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistURLRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistURLRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistURLResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistURLResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistURLResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistBiographyRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistBiographyRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistBiographyRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistBiographyResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistBiographyResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistBiographyResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Biography", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Biography = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistSimilarRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistSimilarRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistSimilarRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Limit", wireType) + } + m.Limit = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Limit |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Artist) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Artist: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Artist: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistSimilarResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistSimilarResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistSimilarResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artists", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artists = append(m.Artists, &Artist{}) + if err := m.Artists[len(m.Artists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistImageRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistImageRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistImageRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ExternalImage) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ExternalImage: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ExternalImage: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType) + } + m.Size = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Size |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistImageResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistImageResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistImageResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Images", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Images = append(m.Images, &ExternalImage{}) + if err := m.Images[len(m.Images)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistTopSongsRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistTopSongsRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistTopSongsRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ArtistName", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ArtistName = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Count", wireType) + } + m.Count = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Count |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Song) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Song: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Song: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistTopSongsResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistTopSongsResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistTopSongsResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Songs", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Songs = append(m.Songs, &Song{}) + if err := m.Songs[len(m.Songs)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumInfoRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumInfoRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumInfoRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artist", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artist = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumInfo) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumInfo: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumInfo: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Description", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Description = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumInfoResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumInfoResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumInfoResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Info", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Info == nil { + m.Info = &AlbumInfo{} + } + if err := m.Info.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumImagesRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumImagesRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumImagesRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artist", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artist = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumImagesResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumImagesResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumImagesResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Images", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Images = append(m.Images, &ExternalImage{}) + if err := m.Images[len(m.Images)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerIsAuthorizedRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.UserId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Username = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerIsAuthorizedResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Authorized", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Authorized = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TrackInfo) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TrackInfo: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TrackInfo: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Album", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Album = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AlbumMbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AlbumMbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artists", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artists = append(m.Artists, &Artist{}) + if err := m.Artists[len(m.Artists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 7: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AlbumArtists", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AlbumArtists = append(m.AlbumArtists, &Artist{}) + if err := m.AlbumArtists[len(m.AlbumArtists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 8: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Length", wireType) + } + m.Length = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Length |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 9: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Position", wireType) + } + m.Position = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Position |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerNowPlayingRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerNowPlayingRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerNowPlayingRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.UserId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Username = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Track", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Track == nil { + m.Track = &TrackInfo{} + } + if err := m.Track.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) + } + m.Timestamp = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Timestamp |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerNowPlayingResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerNowPlayingResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerNowPlayingResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerScrobbleRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerScrobbleRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerScrobbleRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.UserId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Username = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Track", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Track == nil { + m.Track = &TrackInfo{} + } + if err := m.Track.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) + } + m.Timestamp = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Timestamp |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerScrobbleResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerScrobbleResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerScrobbleResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SchedulerCallbackRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SchedulerCallbackRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SchedulerCallbackRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field IsRecurring", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.IsRecurring = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SchedulerCallbackResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SchedulerCallbackResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SchedulerCallbackResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *InitRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: InitRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: InitRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Config == nil { + m.Config = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Config[mapkey] = mapvalue + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *InitResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: InitResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: InitResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnTextMessageRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnTextMessageRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnTextMessageRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Message", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Message = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnTextMessageResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnTextMessageResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnTextMessageResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnBinaryMessageRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnBinaryMessageRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnBinaryMessageRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Data", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Data = append(m.Data[:0], dAtA[iNdEx:postIndex]...) + if m.Data == nil { + m.Data = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnBinaryMessageResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnBinaryMessageResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnBinaryMessageResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnErrorRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnErrorRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnErrorRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnErrorResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnErrorResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnErrorResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnCloseRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnCloseRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnCloseRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Code", wireType) + } + m.Code = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Code |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Reason", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Reason = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnCloseResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnCloseResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnCloseResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/api/errors.go b/plugins/api/errors.go new file mode 100644 index 000000000..e6d952b4f --- /dev/null +++ b/plugins/api/errors.go @@ -0,0 +1,8 @@ +package api + +import "errors" + +var ( + ErrNotFound = errors.New("plugin:not_found") + ErrNotImplemented = errors.New("plugin:not_implemented") +) diff --git a/plugins/discovery.go b/plugins/discovery.go new file mode 100644 index 000000000..4125da322 --- /dev/null +++ b/plugins/discovery.go @@ -0,0 +1,145 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// PluginDiscoveryEntry represents the result of plugin discovery +type PluginDiscoveryEntry struct { + ID string // Plugin ID (directory name) + Path string // Resolved plugin directory path + WasmPath string // Path to the WASM file + Manifest *schema.PluginManifest // Loaded manifest (nil if failed) + IsSymlink bool // Whether the plugin is a development symlink + Error error // Error encountered during discovery +} + +// DiscoverPlugins scans the plugins directory and returns information about all discoverable plugins +// This shared function eliminates duplication between ScanPlugins and plugin list commands +func DiscoverPlugins(pluginsDir string) []PluginDiscoveryEntry { + var discoveries []PluginDiscoveryEntry + + entries, err := os.ReadDir(pluginsDir) + if err != nil { + // Return a single entry with the error + return []PluginDiscoveryEntry{{ + Error: fmt.Errorf("failed to read plugins directory %s: %w", pluginsDir, err), + }} + } + + for _, entry := range entries { + name := entry.Name() + pluginPath := filepath.Join(pluginsDir, name) + + // Skip hidden files + if name[0] == '.' { + continue + } + + // Check if it's a directory or symlink + info, err := os.Lstat(pluginPath) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Error: fmt.Errorf("failed to stat entry %s: %w", pluginPath, err), + }) + continue + } + + isSymlink := info.Mode()&os.ModeSymlink != 0 + isDir := info.IsDir() + + // Skip if not a directory or symlink + if !isDir && !isSymlink { + continue + } + + // Resolve symlinks + pluginDir := pluginPath + if isSymlink { + targetDir, err := os.Readlink(pluginPath) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + IsSymlink: true, + Error: fmt.Errorf("failed to resolve symlink %s: %w", pluginPath, err), + }) + continue + } + + // If target is a relative path, make it absolute + if !filepath.IsAbs(targetDir) { + targetDir = filepath.Join(filepath.Dir(pluginPath), targetDir) + } + + // Verify that the target is a directory + targetInfo, err := os.Stat(targetDir) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + IsSymlink: true, + Error: fmt.Errorf("failed to stat symlink target %s: %w", targetDir, err), + }) + continue + } + + if !targetInfo.IsDir() { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + IsSymlink: true, + Error: fmt.Errorf("symlink target is not a directory: %s", targetDir), + }) + continue + } + + pluginDir = targetDir + } + + // Check for WASM file + wasmPath := filepath.Join(pluginDir, "plugin.wasm") + if _, err := os.Stat(wasmPath); err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + Error: fmt.Errorf("no plugin.wasm found: %w", err), + }) + continue + } + + // Load manifest + manifest, err := LoadManifest(pluginDir) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + Error: fmt.Errorf("failed to load manifest: %w", err), + }) + continue + } + + // Check for capabilities + if len(manifest.Capabilities) == 0 { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + Error: fmt.Errorf("no capabilities found in manifest"), + }) + continue + } + + // Success! + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + WasmPath: wasmPath, + Manifest: manifest, + IsSymlink: isSymlink, + }) + } + + return discoveries +} diff --git a/plugins/discovery_test.go b/plugins/discovery_test.go new file mode 100644 index 000000000..a5fd34516 --- /dev/null +++ b/plugins/discovery_test.go @@ -0,0 +1,402 @@ +package plugins + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("DiscoverPlugins", func() { + var tempPluginsDir string + + // Helper to create a valid plugin for discovery testing + createValidPlugin := func(name, manifestName, author, version string, capabilities []string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "` + manifestName + `", + "version": "` + version + `", + "capabilities": [` + for i, cap := range capabilities { + if i > 0 { + manifest += `, ` + } + manifest += `"` + cap + `"` + } + manifest += `], + "author": "` + author + `", + "description": "Test Plugin", + "website": "https://test.navidrome.org/` + manifestName + `", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + createManifestOnlyPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + manifest := `{ + "name": "manifest-only", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/manifest-only", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + createWasmOnlyPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + } + + createInvalidManifestPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + invalidManifest := `{ "invalid": "json" }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidManifest), 0600)).To(Succeed()) + } + + createEmptyCapabilitiesPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "empty-capabilities", + "version": "1.0.0", + "capabilities": [], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/empty-capabilities", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + BeforeEach(func() { + tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-discovery-test-*") + DeferCleanup(func() { + _ = os.RemoveAll(tempPluginsDir) + }) + }) + + Context("Valid plugins", func() { + It("should discover valid plugins with all required files", func() { + createValidPlugin("test-plugin", "Test Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createValidPlugin("another-plugin", "Another Plugin", "Another Author", "2.0.0", []string{"Scrobbler"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(2)) + + // Find each plugin by ID + var testPlugin, anotherPlugin *PluginDiscoveryEntry + for i := range discoveries { + switch discoveries[i].ID { + case "test-plugin": + testPlugin = &discoveries[i] + case "another-plugin": + anotherPlugin = &discoveries[i] + } + } + + Expect(testPlugin).NotTo(BeNil()) + Expect(testPlugin.Error).To(BeNil()) + Expect(testPlugin.Manifest.Name).To(Equal("Test Plugin")) + Expect(string(testPlugin.Manifest.Capabilities[0])).To(Equal("MetadataAgent")) + + Expect(anotherPlugin).NotTo(BeNil()) + Expect(anotherPlugin.Error).To(BeNil()) + Expect(anotherPlugin.Manifest.Name).To(Equal("Another Plugin")) + Expect(string(anotherPlugin.Manifest.Capabilities[0])).To(Equal("Scrobbler")) + }) + + It("should handle plugins with same manifest name in different directories", func() { + createValidPlugin("lastfm-official", "lastfm", "Official Author", "1.0.0", []string{"MetadataAgent"}) + createValidPlugin("lastfm-custom", "lastfm", "Custom Author", "2.0.0", []string{"MetadataAgent"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(2)) + + // Find each plugin by ID + var officialPlugin, customPlugin *PluginDiscoveryEntry + for i := range discoveries { + switch discoveries[i].ID { + case "lastfm-official": + officialPlugin = &discoveries[i] + case "lastfm-custom": + customPlugin = &discoveries[i] + } + } + + Expect(officialPlugin).NotTo(BeNil()) + Expect(officialPlugin.Error).To(BeNil()) + Expect(officialPlugin.Manifest.Name).To(Equal("lastfm")) + Expect(officialPlugin.Manifest.Author).To(Equal("Official Author")) + + Expect(customPlugin).NotTo(BeNil()) + Expect(customPlugin.Error).To(BeNil()) + Expect(customPlugin.Manifest.Name).To(Equal("lastfm")) + Expect(customPlugin.Manifest.Author).To(Equal("Custom Author")) + }) + }) + + Context("Missing files", func() { + It("should report error for plugins missing WASM files", func() { + createManifestOnlyPlugin("manifest-only") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("manifest-only")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("no plugin.wasm found")) + }) + + It("should skip directories missing manifest files", func() { + createWasmOnlyPlugin("wasm-only") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("wasm-only")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest")) + }) + }) + + Context("Invalid content", func() { + It("should report error for invalid manifest JSON", func() { + createInvalidManifestPlugin("invalid-manifest") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("invalid-manifest")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest")) + }) + + It("should report error for plugins with empty capabilities", func() { + createEmptyCapabilitiesPlugin("empty-capabilities") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("empty-capabilities")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("field capabilities length: must be >= 1")) + }) + }) + + Context("Symlinks", func() { + It("should discover symlinked plugins correctly", func() { + // Create a real plugin directory outside tempPluginsDir + realPluginDir, err := os.MkdirTemp("", "navidrome-real-plugin-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _ = os.RemoveAll(realPluginDir) + }) + + // Create plugin files in the real directory + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "symlinked-plugin", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/symlinked-plugin", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + + // Create symlink + symlinkPath := filepath.Join(tempPluginsDir, "symlinked-plugin") + Expect(os.Symlink(realPluginDir, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("symlinked-plugin")) + Expect(discoveries[0].Error).To(BeNil()) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + Expect(discoveries[0].Path).To(Equal(realPluginDir)) + Expect(discoveries[0].Manifest.Name).To(Equal("symlinked-plugin")) + }) + + It("should handle relative symlinks", func() { + // Create a real plugin directory in the same parent as tempPluginsDir + parentDir := filepath.Dir(tempPluginsDir) + realPluginDir := filepath.Join(parentDir, "real-plugin-dir") + Expect(os.MkdirAll(realPluginDir, 0755)).To(Succeed()) + DeferCleanup(func() { + _ = os.RemoveAll(realPluginDir) + }) + + // Create plugin files in the real directory + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "relative-symlinked-plugin", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/relative-symlinked-plugin", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + + // Create relative symlink + symlinkPath := filepath.Join(tempPluginsDir, "relative-symlinked-plugin") + relativeTarget := "../real-plugin-dir" + Expect(os.Symlink(relativeTarget, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("relative-symlinked-plugin")) + Expect(discoveries[0].Error).To(BeNil()) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + Expect(discoveries[0].Path).To(Equal(realPluginDir)) + Expect(discoveries[0].Manifest.Name).To(Equal("relative-symlinked-plugin")) + }) + + It("should report error for broken symlinks", func() { + symlinkPath := filepath.Join(tempPluginsDir, "broken-symlink") + nonExistentTarget := "/non/existent/path" + Expect(os.Symlink(nonExistentTarget, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("broken-symlink")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to stat symlink target")) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + }) + + It("should report error for symlinks pointing to files", func() { + // Create a regular file + regularFile := filepath.Join(tempPluginsDir, "regular-file.txt") + Expect(os.WriteFile(regularFile, []byte("content"), 0600)).To(Succeed()) + + // Create symlink pointing to the file + symlinkPath := filepath.Join(tempPluginsDir, "symlink-to-file") + Expect(os.Symlink(regularFile, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("symlink-to-file")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("symlink target is not a directory")) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + }) + }) + + Context("Directory filtering", func() { + It("should ignore hidden directories", func() { + createValidPlugin(".hidden-plugin", "Hidden Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createValidPlugin("visible-plugin", "Visible Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("visible-plugin")) + }) + + It("should ignore regular files", func() { + // Create a regular file + Expect(os.WriteFile(filepath.Join(tempPluginsDir, "regular-file.txt"), []byte("content"), 0600)).To(Succeed()) + createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("valid-plugin")) + }) + + It("should handle mixed valid and invalid plugins", func() { + createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createManifestOnlyPlugin("manifest-only") + createInvalidManifestPlugin("invalid-manifest") + createValidPlugin("another-valid", "Another Valid", "Test Author", "1.0.0", []string{"Scrobbler"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(4)) + + var validCount int + var errorCount int + for _, discovery := range discoveries { + if discovery.Error == nil { + validCount++ + } else { + errorCount++ + } + } + + Expect(validCount).To(Equal(2)) + Expect(errorCount).To(Equal(2)) + }) + }) + + Context("Error handling", func() { + It("should handle non-existent plugins directory", func() { + nonExistentDir := "/non/existent/plugins/dir" + + discoveries := DiscoverPlugins(nonExistentDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to read plugins directory")) + }) + }) +}) diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile new file mode 100644 index 000000000..e2acc2ff8 --- /dev/null +++ b/plugins/examples/Makefile @@ -0,0 +1,27 @@ +all: wikimedia coverartarchive crypto-ticker discord-rich-presence subsonicapi-demo + +wikimedia: wikimedia/plugin.wasm +coverartarchive: coverartarchive/plugin.wasm +crypto-ticker: crypto-ticker/plugin.wasm +discord-rich-presence: discord-rich-presence/plugin.wasm +subsonicapi-demo: subsonicapi-demo/plugin.wasm + +wikimedia/plugin.wasm: wikimedia/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia + +coverartarchive/plugin.wasm: coverartarchive/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive + +crypto-ticker/plugin.wasm: crypto-ticker/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker + +DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go") +discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES) + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/... + +subsonicapi-demo/plugin.wasm: subsonicapi-demo/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./subsonicapi-demo + +clean: + rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm \ + discord-rich-presence/plugin.wasm subsonicapi-demo/plugin.wasm \ No newline at end of file diff --git a/plugins/examples/README.md b/plugins/examples/README.md new file mode 100644 index 000000000..6527026fd --- /dev/null +++ b/plugins/examples/README.md @@ -0,0 +1,31 @@ +# Plugin Examples + +This directory contains example plugins for Navidrome, intended for demonstration and reference purposes. These plugins are not used in automated tests. + +## Contents + +- `wikimedia/`: Example plugin that retrieves artist information from Wikidata. +- `coverartarchive/`: Example plugin that retrieves album cover images from the Cover Art Archive. +- `crypto-ticker/`: Example plugin using websockets to log real-time cryptocurrency prices. +- `discord-rich-presence/`: Example plugin that integrates with Discord Rich Presence to display currently playing tracks on Discord profiles. +- `subsonicapi-demo/`: Example plugin that demonstrates how to interact with the Navidrome's Subsonic API from a plugin. + +## Building + +To build all example plugins, run: + +``` +make +``` + +Or to build a specific plugin: + +``` +make wikimedia +make coverartarchive +make crypto-ticker +make discord-rich-presence +make subsonicapi-demo +``` + +This will produce the corresponding `plugin.wasm` files in each plugin's directory. diff --git a/plugins/examples/coverartarchive/README.md b/plugins/examples/coverartarchive/README.md new file mode 100644 index 000000000..e886f6871 --- /dev/null +++ b/plugins/examples/coverartarchive/README.md @@ -0,0 +1,34 @@ +# Cover Art Archive AlbumMetadataService Plugin + +This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release Group MBID. + +## Features + +- Implements only the `GetAlbumImages` method of the AlbumMetadataService plugin interface. +- Returns front cover images for a given release-group MBID. +- Returns `not found` if no MBID is provided or no images are found. + +## Requirements + +- Go 1.24 or newer (with WASI support) +- The Navidrome repository (with generated plugin API code in `plugins/api`) + +## How to Compile + +To build the WASM plugin, run the following command from the project root: + +```sh +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugins/testdata/coverartarchive/plugin.wasm ./plugins/testdata/coverartarchive +``` + +This will produce `plugin.wasm` in this directory. + +## Usage + +- The plugin can be loaded by Navidrome for integration and end-to-end tests of the plugin system. +- It is intended for testing and development purposes only. + +## API Reference + +- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API) +- This plugin uses the endpoint: `https://coverartarchive.org/release-group/{mbid}` diff --git a/plugins/examples/coverartarchive/manifest.json b/plugins/examples/coverartarchive/manifest.json new file mode 100644 index 000000000..4049fc358 --- /dev/null +++ b/plugins/examples/coverartarchive/manifest.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "coverartarchive", + "author": "Navidrome", + "version": "1.0.0", + "description": "Album cover art from the Cover Art Archive", + "website": "https://coverartarchive.org", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch album cover art from the Cover Art Archive API", + "allowedUrls": { + "https://coverartarchive.org": ["GET"], + "https://*.archive.org": ["GET"] + }, + "allowLocalNetwork": false + } + } +} diff --git a/plugins/examples/coverartarchive/plugin.go b/plugins/examples/coverartarchive/plugin.go new file mode 100644 index 000000000..f91546de3 --- /dev/null +++ b/plugins/examples/coverartarchive/plugin.go @@ -0,0 +1,147 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/http" +) + +type CoverArtArchiveAgent struct{} + +var ErrNotFound = api.ErrNotFound + +type caaImage struct { + Image string `json:"image"` + Front bool `json:"front"` + Types []string `json:"types"` + Thumbnails map[string]string `json:"thumbnails"` +} + +var client = http.NewHttpService() + +func (CoverArtArchiveAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + if req.Mbid == "" { + return nil, ErrNotFound + } + + url := "https://coverartarchive.org/release/" + req.Mbid + resp, err := client.Get(ctx, &http.HttpRequest{Url: url, TimeoutMs: 5000}) + if err != nil || resp.Status != 200 { + log.Printf("[CAA] Error getting album images from CoverArtArchive (status: %d): %v", resp.Status, err) + return nil, ErrNotFound + } + + images, err := extractFrontImages(resp.Body) + if err != nil || len(images) == 0 { + return nil, ErrNotFound + } + return &api.AlbumImagesResponse{Images: images}, nil +} + +func extractFrontImages(body []byte) ([]*api.ExternalImage, error) { + var data struct { + Images []caaImage `json:"images"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + img := findFrontImage(data.Images) + if img == nil { + return nil, ErrNotFound + } + return buildImageList(img), nil +} + +func findFrontImage(images []caaImage) *caaImage { + for i, img := range images { + if img.Front { + return &images[i] + } + } + for i, img := range images { + for _, t := range img.Types { + if t == "Front" { + return &images[i] + } + } + } + if len(images) > 0 { + return &images[0] + } + return nil +} + +func buildImageList(img *caaImage) []*api.ExternalImage { + var images []*api.ExternalImage + // First, try numeric sizes only + for sizeStr, url := range img.Thumbnails { + if url == "" { + continue + } + size := 0 + if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil { + images = append(images, &api.ExternalImage{Url: url, Size: int32(size)}) + } + } + // If no numeric sizes, fallback to large/small + if len(images) == 0 { + for sizeStr, url := range img.Thumbnails { + if url == "" { + continue + } + var size int + switch sizeStr { + case "large": + size = 500 + case "small": + size = 250 + default: + continue + } + images = append(images, &api.ExternalImage{Url: url, Size: int32(size)}) + } + } + if len(images) == 0 && img.Image != "" { + images = append(images, &api.ExternalImage{Url: img.Image, Size: 0}) + } + return images +} + +func (CoverArtArchiveAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + return nil, api.ErrNotImplemented +} +func (CoverArtArchiveAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, api.ErrNotImplemented +} + +func main() {} + +func init() { + api.RegisterMetadataAgent(CoverArtArchiveAgent{}) +} diff --git a/plugins/examples/crypto-ticker/README.md b/plugins/examples/crypto-ticker/README.md new file mode 100644 index 000000000..c550ebfe9 --- /dev/null +++ b/plugins/examples/crypto-ticker/README.md @@ -0,0 +1,53 @@ +# Crypto Ticker Plugin + +This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase. + +## Features + +- Connects to Coinbase WebSocket API to receive real-time ticker updates +- Configurable to track multiple cryptocurrency pairs +- Implements WebSocketCallback and LifecycleManagement interfaces +- Automatically reconnects on connection loss +- Displays price, best bid, best ask, and 24-hour percentage change + +## Configuration + +In your `navidrome.toml` file, add: + +```toml +[PluginSettings.crypto-ticker] +tickers = "BTC,ETH,SOL,MATIC" +``` + +- `tickers` is a comma-separated list of cryptocurrency symbols +- The plugin will append `-USD` to any symbol without a trading pair specified + +## How it Works + +- The plugin connects to Coinbase's WebSocket API upon initialization +- It subscribes to ticker updates for the configured cryptocurrencies +- Incoming ticker data is processed and logged +- On connection loss, it automatically attempts to reconnect (TODO) + +## Building + +To build the plugin to WASM: + +``` +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go +``` + +## Installation + +Copy the resulting `plugin.wasm` and create a `manifest.json` file in your Navidrome plugins folder under a `crypto-ticker` directory. + +## Example Output + +``` +CRYPTO TICKER: BTC-USD Price: 65432.50 Best Bid: 65431.25 Best Ask: 65433.75 24h Change: 2.75% +CRYPTO TICKER: ETH-USD Price: 3456.78 Best Bid: 3455.90 Best Ask: 3457.80 24h Change: 1.25% +``` + +--- + +For more details, see the source code in `plugin.go`. diff --git a/plugins/examples/crypto-ticker/manifest.json b/plugins/examples/crypto-ticker/manifest.json new file mode 100644 index 000000000..482731684 --- /dev/null +++ b/plugins/examples/crypto-ticker/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "crypto-ticker", + "author": "Navidrome Plugin", + "version": "1.0.0", + "description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API", + "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker", + "capabilities": [ + "WebSocketCallback", + "LifecycleManagement", + "SchedulerCallback" + ], + "permissions": { + "config": { + "reason": "To read API configuration and WebSocket endpoint settings" + }, + "scheduler": { + "reason": "To schedule periodic reconnection attempts and status updates" + }, + "websocket": { + "reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices", + "allowedUrls": ["wss://ws-feed.exchange.coinbase.com"], + "allowLocalNetwork": false + } + } +} diff --git a/plugins/examples/crypto-ticker/plugin.go b/plugins/examples/crypto-ticker/plugin.go new file mode 100644 index 000000000..e7c646c21 --- /dev/null +++ b/plugins/examples/crypto-ticker/plugin.go @@ -0,0 +1,300 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/config" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/websocket" +) + +const ( + // Coinbase WebSocket API endpoint + coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com" + + // Connection ID for our WebSocket connection + connectionID = "crypto-ticker-connection" + + // ID for the reconnection schedule + reconnectScheduleID = "crypto-ticker-reconnect" +) + +var ( + // Store ticker symbols from the configuration + tickers []string +) + +// WebSocketService instance used to manage WebSocket connections and communication. +var wsService = websocket.NewWebSocketService() + +// ConfigService instance for accessing plugin configuration. +var configService = config.NewConfigService() + +// SchedulerService instance for scheduling tasks. +var schedService = scheduler.NewSchedulerService() + +// CryptoTickerPlugin implements WebSocketCallback, LifecycleManagement, and SchedulerCallback interfaces +type CryptoTickerPlugin struct{} + +// Coinbase subscription message structure +type CoinbaseSubscription struct { + Type string `json:"type"` + ProductIDs []string `json:"product_ids"` + Channels []string `json:"channels"` +} + +// Coinbase ticker message structure +type CoinbaseTicker struct { + Type string `json:"type"` + Sequence int64 `json:"sequence"` + ProductID string `json:"product_id"` + Price string `json:"price"` + Open24h string `json:"open_24h"` + Volume24h string `json:"volume_24h"` + Low24h string `json:"low_24h"` + High24h string `json:"high_24h"` + Volume30d string `json:"volume_30d"` + BestBid string `json:"best_bid"` + BestAsk string `json:"best_ask"` + Side string `json:"side"` + Time string `json:"time"` + TradeID int `json:"trade_id"` + LastSize string `json:"last_size"` +} + +// OnInit is called when the plugin is loaded +func (CryptoTickerPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("Crypto Ticker Plugin initializing...") + + // Check if ticker configuration exists + tickerConfig, ok := req.Config["tickers"] + if !ok { + return &api.InitResponse{Error: "Missing 'tickers' configuration"}, nil + } + + // Parse ticker symbols + tickers := parseTickerSymbols(tickerConfig) + log.Printf("Configured tickers: %v", tickers) + + // Connect to WebSocket and subscribe to tickers + err := connectAndSubscribe(ctx, tickers) + if err != nil { + return &api.InitResponse{Error: err.Error()}, nil + } + + return &api.InitResponse{}, nil +} + +// Helper function to parse ticker symbols from a comma-separated string +func parseTickerSymbols(tickerConfig string) []string { + tickers := strings.Split(tickerConfig, ",") + for i, ticker := range tickers { + tickers[i] = strings.TrimSpace(ticker) + + // Add -USD suffix if not present + if !strings.Contains(tickers[i], "-") { + tickers[i] = tickers[i] + "-USD" + } + } + return tickers +} + +// Helper function to connect to WebSocket and subscribe to tickers +func connectAndSubscribe(ctx context.Context, tickers []string) error { + // Connect to the WebSocket API + _, err := wsService.Connect(ctx, &websocket.ConnectRequest{ + Url: coinbaseWSEndpoint, + ConnectionId: connectionID, + }) + + if err != nil { + log.Printf("Failed to connect to Coinbase WebSocket API: %v", err) + return fmt.Errorf("WebSocket connection error: %v", err) + } + + log.Printf("Connected to Coinbase WebSocket API") + + // Subscribe to ticker channel for the configured symbols + subscription := CoinbaseSubscription{ + Type: "subscribe", + ProductIDs: tickers, + Channels: []string{"ticker"}, + } + + subscriptionJSON, err := json.Marshal(subscription) + if err != nil { + log.Printf("Failed to marshal subscription message: %v", err) + return fmt.Errorf("JSON marshal error: %v", err) + } + + // Send subscription message + _, err = wsService.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: connectionID, + Message: string(subscriptionJSON), + }) + + if err != nil { + log.Printf("Failed to send subscription message: %v", err) + return fmt.Errorf("WebSocket send error: %v", err) + } + + log.Printf("Subscription message sent to Coinbase WebSocket API") + return nil +} + +// OnTextMessage is called when a text message is received from the WebSocket +func (CryptoTickerPlugin) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) { + // Only process messages from our connection + if req.ConnectionId != connectionID { + log.Printf("Received message from unexpected connection: %s", req.ConnectionId) + return &api.OnTextMessageResponse{}, nil + } + + // Try to parse as a ticker message + var ticker CoinbaseTicker + err := json.Unmarshal([]byte(req.Message), &ticker) + if err != nil { + log.Printf("Failed to parse ticker message: %v", err) + return &api.OnTextMessageResponse{}, nil + } + + // If the message is not a ticker or has an error, just log it + if ticker.Type != "ticker" { + // This could be subscription confirmation or other messages + log.Printf("Received non-ticker message: %s", req.Message) + return &api.OnTextMessageResponse{}, nil + } + + // Format and print ticker information + log.Printf("CRYPTO TICKER: %s Price: %s Best Bid: %s Best Ask: %s 24h Change: %s%%\n", + ticker.ProductID, + ticker.Price, + ticker.BestBid, + ticker.BestAsk, + calculatePercentChange(ticker.Open24h, ticker.Price), + ) + + return &api.OnTextMessageResponse{}, nil +} + +// OnBinaryMessage is called when a binary message is received +func (CryptoTickerPlugin) OnBinaryMessage(ctx context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) { + // Not expected from Coinbase WebSocket API + return &api.OnBinaryMessageResponse{}, nil +} + +// OnError is called when an error occurs on the WebSocket connection +func (CryptoTickerPlugin) OnError(ctx context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) { + log.Printf("WebSocket error: %s", req.Error) + return &api.OnErrorResponse{}, nil +} + +// OnClose is called when the WebSocket connection is closed +func (CryptoTickerPlugin) OnClose(ctx context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) { + log.Printf("WebSocket connection closed with code %d: %s", req.Code, req.Reason) + + // Try to reconnect if this is our connection + if req.ConnectionId == connectionID { + log.Printf("Scheduling reconnection attempts every 2 seconds...") + + // Create a recurring schedule to attempt reconnection every 2 seconds + resp, err := schedService.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + // Run every 2 seconds using cron expression + CronExpression: "*/2 * * * * *", + ScheduleId: reconnectScheduleID, + }) + + if err != nil { + log.Printf("Failed to schedule reconnection attempts: %v", err) + } else { + log.Printf("Reconnection schedule created with ID: %s", resp.ScheduleId) + } + } + + return &api.OnCloseResponse{}, nil +} + +// OnSchedulerCallback is called when a scheduled event triggers +func (CryptoTickerPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + // Only handle our reconnection schedule + if req.ScheduleId != reconnectScheduleID { + log.Printf("Received callback for unknown schedule: %s", req.ScheduleId) + return &api.SchedulerCallbackResponse{}, nil + } + + log.Printf("Attempting to reconnect to Coinbase WebSocket API...") + + // Get the current ticker configuration + configResp, err := configService.GetPluginConfig(ctx, &config.GetPluginConfigRequest{}) + if err != nil { + log.Printf("Failed to get plugin configuration: %v", err) + return &api.SchedulerCallbackResponse{Error: fmt.Sprintf("Config error: %v", err)}, nil + } + + // Check if ticker configuration exists + tickerConfig, ok := configResp.Config["tickers"] + if !ok { + log.Printf("Missing 'tickers' configuration") + return &api.SchedulerCallbackResponse{Error: "Missing 'tickers' configuration"}, nil + } + + // Parse ticker symbols + tickers := parseTickerSymbols(tickerConfig) + log.Printf("Reconnecting with tickers: %v", tickers) + + // Try to connect and subscribe + err = connectAndSubscribe(ctx, tickers) + if err != nil { + log.Printf("Reconnection attempt failed: %v", err) + return &api.SchedulerCallbackResponse{Error: err.Error()}, nil + } + + // Successfully reconnected, cancel the reconnection schedule + _, err = schedService.CancelSchedule(ctx, &scheduler.CancelRequest{ + ScheduleId: reconnectScheduleID, + }) + + if err != nil { + log.Printf("Failed to cancel reconnection schedule: %v", err) + } else { + log.Printf("Reconnection schedule canceled after successful reconnection") + } + + return &api.SchedulerCallbackResponse{}, nil +} + +// Helper function to calculate percent change +func calculatePercentChange(open, current string) string { + var openFloat, currentFloat float64 + _, err := fmt.Sscanf(open, "%f", &openFloat) + if err != nil { + return "N/A" + } + _, err = fmt.Sscanf(current, "%f", ¤tFloat) + if err != nil { + return "N/A" + } + + if openFloat == 0 { + return "N/A" + } + + change := ((currentFloat - openFloat) / openFloat) * 100 + return fmt.Sprintf("%.2f", change) +} + +// Required by Go WASI build +func main() {} + +func init() { + api.RegisterWebSocketCallback(CryptoTickerPlugin{}) + api.RegisterLifecycleManagement(CryptoTickerPlugin{}) + api.RegisterSchedulerCallback(CryptoTickerPlugin{}) +} diff --git a/plugins/examples/discord-rich-presence/README.md b/plugins/examples/discord-rich-presence/README.md new file mode 100644 index 000000000..8cb97224a --- /dev/null +++ b/plugins/examples/discord-rich-presence/README.md @@ -0,0 +1,88 @@ +# Discord Rich Presence Plugin + +This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time +connection to an external service while remaining completely stateless. This plugin is based on the +[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality. + +**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the +Navidrome configuration file, which is not secure, and may be against Discord's terms of service. +Use it at your own risk.** + +## Overview + +The plugin exposes three capabilities: + +- **Scrobbler** – receives `NowPlaying` notifications from Navidrome +- **WebSocketCallback** – handles Discord gateway messages +- **SchedulerCallback** – used to clear presence and send periodic heartbeats + +It relies on several host services declared in `manifest.json`: + +- `http` – queries Discord API endpoints +- `websocket` – maintains gateway connections +- `scheduler` – schedules heartbeats and presence cleanup +- `cache` – stores sequence numbers for heartbeats +- `config` – retrieves the plugin configuration on each call +- `artwork` – resolves track artwork URLs + +## Architecture + +Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the +scheduler service: + +```go +api.RegisterScrobbler(plugin) +api.RegisterWebSocketCallback(plugin.rpc) +plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin) +plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc) +``` + +When `NowPlaying` is invoked the plugin: + +1. Loads `clientid` and user tokens from the configuration (because plugins are stateless). +2. Connects to Discord using `WebSocketService` if no connection exists. +3. Sends the activity payload with track details and artwork. +4. Schedules a one‑time callback to clear the presence after the track finishes. + +Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in +`CacheService` to remain available across plugin instances. + +The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached. + +```go +// The plugin is stateless, we need to load the configuration every time +clientID, users, err := d.getConfig(ctx) +``` + +## Configuration + +Add the following to `navidrome.toml` and adjust for your tokens: + +```toml +[PluginSettings.discord-rich-presence] +ClientID = "123456789012345678" +Users = "alice:token123,bob:token456" +``` + +- `clientid` is your Discord application ID +- `users` is a comma‑separated list of `username:token` pairs used for authorization + +## Building + +```sh +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/... +``` + +Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins +directory. + +## Stateless Operation + +Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it +afterwards. + +To work within this model the plugin stores no in-memory state. Connections are keyed by user name inside the host +services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every +method call. + +For more implementation details see `plugin.go` and `rpc.go`. diff --git a/plugins/examples/discord-rich-presence/manifest.json b/plugins/examples/discord-rich-presence/manifest.json new file mode 100644 index 000000000..c6fa9c283 --- /dev/null +++ b/plugins/examples/discord-rich-presence/manifest.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "discord-rich-presence", + "author": "Navidrome Team", + "version": "1.0.0", + "description": "Discord Rich Presence integration for Navidrome", + "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence", + "capabilities": ["Scrobbler", "SchedulerCallback", "WebSocketCallback"], + "permissions": { + "http": { + "reason": "To communicate with Discord API for gateway discovery and image uploads", + "allowedUrls": { + "https://discord.com/api/*": ["GET", "POST"] + }, + "allowLocalNetwork": false + }, + "websocket": { + "reason": "To maintain real-time connection with Discord gateway", + "allowedUrls": ["wss://gateway.discord.gg"], + "allowLocalNetwork": false + }, + "config": { + "reason": "To access plugin configuration (client ID and user tokens)" + }, + "cache": { + "reason": "To store connection state and sequence numbers" + }, + "scheduler": { + "reason": "To schedule heartbeat messages and activity clearing" + }, + "artwork": { + "reason": "To get track artwork URLs for rich presence display" + } + } +} diff --git a/plugins/examples/discord-rich-presence/plugin.go b/plugins/examples/discord-rich-presence/plugin.go new file mode 100644 index 000000000..c93ccf35d --- /dev/null +++ b/plugins/examples/discord-rich-presence/plugin.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/artwork" + "github.com/navidrome/navidrome/plugins/host/cache" + "github.com/navidrome/navidrome/plugins/host/config" + "github.com/navidrome/navidrome/plugins/host/http" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/websocket" + "github.com/navidrome/navidrome/utils/slice" +) + +type DiscordRPPlugin struct { + rpc *discordRPC + cfg config.ConfigService + artwork artwork.ArtworkService + sched scheduler.SchedulerService +} + +func (d *DiscordRPPlugin) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) { + // Get plugin configuration + _, users, err := d.getConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check user authorization: %w", err) + } + + // Check if the user has a Discord token configured + _, authorized := users[req.Username] + log.Printf("IsAuthorized for user %s: %v", req.Username, authorized) + return &api.ScrobblerIsAuthorizedResponse{ + Authorized: authorized, + }, nil +} + +func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) { + log.Printf("Setting presence for user %s, track: %s", request.Username, request.Track.Name) + + // The plugin is stateless, we need to load the configuration every time + clientID, users, err := d.getConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get config: %w", err) + } + + // Check if the user has a Discord token configured + userToken, authorized := users[request.Username] + if !authorized { + return nil, fmt.Errorf("user '%s' not authorized", request.Username) + } + + // Make sure we have a connection + if err := d.rpc.connect(ctx, request.Username, userToken); err != nil { + return nil, fmt.Errorf("failed to connect to Discord: %w", err) + } + + // Cancel any existing completion schedule + if resp, _ := d.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: request.Username}); resp.Error != "" { + log.Printf("Ignoring failure to cancel schedule: %s", resp.Error) + } + + // Send activity update + if err := d.rpc.sendActivity(ctx, clientID, request.Username, userToken, activity{ + Application: clientID, + Name: "Navidrome", + Type: 2, + Details: request.Track.Name, + State: d.getArtistList(request.Track), + Timestamps: activityTimestamps{ + Start: (request.Timestamp - int64(request.Track.Position)) * 1000, + End: (request.Timestamp - int64(request.Track.Position) + int64(request.Track.Length)) * 1000, + }, + Assets: activityAssets{ + LargeImage: d.imageURL(ctx, request), + LargeText: request.Track.Album, + }, + }); err != nil { + return nil, fmt.Errorf("failed to send activity: %w", err) + } + + // Schedule a timer to clear the activity after the track completes + _, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{ + ScheduleId: request.Username, + DelaySeconds: request.Track.Length - request.Track.Position + 5, + }) + if err != nil { + return nil, fmt.Errorf("failed to schedule completion timer: %w", err) + } + + return nil, nil +} + +func (d *DiscordRPPlugin) imageURL(ctx context.Context, request *api.ScrobblerNowPlayingRequest) string { + imageResp, _ := d.artwork.GetTrackUrl(ctx, &artwork.GetArtworkUrlRequest{Id: request.Track.Id, Size: 300}) + imageURL := imageResp.Url + if strings.HasPrefix(imageURL, "http://localhost") { + return "" + } + return imageURL +} + +func (d *DiscordRPPlugin) getArtistList(track *api.TrackInfo) string { + return strings.Join(slice.Map(track.Artists, func(a *api.Artist) string { return a.Name }), " • ") +} + +func (d *DiscordRPPlugin) Scrobble(context.Context, *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) { + return nil, nil +} + +func (d *DiscordRPPlugin) getConfig(ctx context.Context) (string, map[string]string, error) { + const ( + clientIDKey = "clientid" + usersKey = "users" + ) + confResp, err := d.cfg.GetPluginConfig(ctx, &config.GetPluginConfigRequest{}) + if err != nil { + return "", nil, fmt.Errorf("unable to load config: %w", err) + } + conf := confResp.GetConfig() + if len(conf) < 1 { + log.Print("missing configuration") + return "", nil, nil + } + clientID := conf[clientIDKey] + if clientID == "" { + log.Printf("missing ClientID: %v", conf) + return "", nil, nil + } + cfgUsers := conf[usersKey] + if len(cfgUsers) == 0 { + log.Print("no users configured") + return "", nil, nil + } + users := map[string]string{} + for _, user := range strings.Split(cfgUsers, ",") { + tuple := strings.Split(user, ":") + if len(tuple) != 2 { + return clientID, nil, fmt.Errorf("invalid user config: %s", user) + } + users[tuple[0]] = tuple[1] + } + return clientID, users, nil +} + +func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + log.Printf("Removing presence for user %s", req.ScheduleId) + if err := d.rpc.clearActivity(ctx, req.ScheduleId); err != nil { + return nil, fmt.Errorf("failed to clear activity: %w", err) + } + log.Printf("Disconnecting user %s", req.ScheduleId) + if err := d.rpc.disconnect(ctx, req.ScheduleId); err != nil { + return nil, fmt.Errorf("failed to disconnect from Discord: %w", err) + } + return nil, nil +} + +// Creates a new instance of the DiscordRPPlugin, with all host services as dependencies +var plugin = &DiscordRPPlugin{ + cfg: config.NewConfigService(), + artwork: artwork.NewArtworkService(), + rpc: &discordRPC{ + ws: websocket.NewWebSocketService(), + web: http.NewHttpService(), + mem: cache.NewCacheService(), + }, +} + +func init() { + // Configure logging: No timestamps, no source file/line, prepend [Discord] + log.SetFlags(0) + log.SetPrefix("[Discord] ") + + // Register plugin capabilities + api.RegisterScrobbler(plugin) + api.RegisterWebSocketCallback(plugin.rpc) + + // Register named scheduler callbacks, and get the scheduler service for each + plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin) + plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc) +} + +func main() {} diff --git a/plugins/examples/discord-rich-presence/rpc.go b/plugins/examples/discord-rich-presence/rpc.go new file mode 100644 index 000000000..4b383c53a --- /dev/null +++ b/plugins/examples/discord-rich-presence/rpc.go @@ -0,0 +1,365 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/cache" + "github.com/navidrome/navidrome/plugins/host/http" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/websocket" +) + +type discordRPC struct { + ws websocket.WebSocketService + web http.HttpService + mem cache.CacheService + sched scheduler.SchedulerService +} + +// Discord WebSocket Gateway constants +const ( + heartbeatOpCode = 1 // Heartbeat operation code + gateOpCode = 2 // Identify operation code + presenceOpCode = 3 // Presence update operation code +) + +const ( + heartbeatInterval = 41 // Heartbeat interval in seconds + defaultImage = "https://i.imgur.com/hb3XPzA.png" +) + +// Activity is a struct that represents an activity in Discord. +type activity struct { + Name string `json:"name"` + Type int `json:"type"` + Details string `json:"details"` + State string `json:"state"` + Application string `json:"application_id"` + Timestamps activityTimestamps `json:"timestamps"` + Assets activityAssets `json:"assets"` +} + +type activityTimestamps struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +type activityAssets struct { + LargeImage string `json:"large_image"` + LargeText string `json:"large_text"` +} + +// PresencePayload is a struct that represents a presence update in Discord. +type presencePayload struct { + Activities []activity `json:"activities"` + Since int64 `json:"since"` + Status string `json:"status"` + Afk bool `json:"afk"` +} + +// IdentifyPayload is a struct that represents an identify payload in Discord. +type identifyPayload struct { + Token string `json:"token"` + Intents int `json:"intents"` + Properties identifyProperties `json:"properties"` +} + +type identifyProperties struct { + OS string `json:"os"` + Browser string `json:"browser"` + Device string `json:"device"` +} + +func (r *discordRPC) processImage(ctx context.Context, imageURL string, clientID string, token string) (string, error) { + return r.processImageWithFallback(ctx, imageURL, clientID, token, false) +} + +func (r *discordRPC) processImageWithFallback(ctx context.Context, imageURL string, clientID string, token string, isDefaultImage bool) (string, error) { + // Check if context is canceled + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("context canceled: %w", err) + } + + if imageURL == "" { + if isDefaultImage { + // We're already processing the default image and it's empty, return error + return "", fmt.Errorf("default image URL is empty") + } + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + if strings.HasPrefix(imageURL, "mp:") { + return imageURL, nil + } + + // Check cache first + cacheKey := fmt.Sprintf("discord.image.%x", imageURL) + cacheResp, _ := r.mem.GetString(ctx, &cache.GetRequest{Key: cacheKey}) + if cacheResp.Exists { + log.Printf("Cache hit for image URL: %s", imageURL) + return cacheResp.Value, nil + } + + resp, _ := r.web.Post(ctx, &http.HttpRequest{ + Url: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID), + Headers: map[string]string{ + "Authorization": token, + "Content-Type": "application/json", + }, + Body: fmt.Appendf(nil, `{"urls":[%q]}`, imageURL), + }) + + // Handle HTTP error responses + if resp.Status >= 400 { + if isDefaultImage { + return "", fmt.Errorf("failed to process default image: HTTP %d %s", resp.Status, resp.Error) + } + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + if resp.Error != "" { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("failed to process default image: %s", resp.Error) + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + var data []map[string]string + if err := json.Unmarshal(resp.Body, &data); err != nil { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("failed to unmarshal default image response: %w", err) + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + if len(data) == 0 { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("no data returned for default image") + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + image := data[0]["external_asset_path"] + if image == "" { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("empty external_asset_path for default image") + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + processedImage := fmt.Sprintf("mp:%s", image) + + // Cache the processed image URL + var ttl = 4 * time.Hour // 4 hours for regular images + if isDefaultImage { + ttl = 48 * time.Hour // 48 hours for default image + } + + _, _ = r.mem.SetString(ctx, &cache.SetStringRequest{ + Key: cacheKey, + Value: processedImage, + TtlSeconds: int64(ttl.Seconds()), + }) + + log.Printf("Cached processed image URL for %s (TTL: %s seconds)", imageURL, ttl) + + return processedImage, nil +} + +func (r *discordRPC) sendActivity(ctx context.Context, clientID, username, token string, data activity) error { + log.Printf("Sending activity to for user %s: %#v", username, data) + + processedImage, err := r.processImage(ctx, data.Assets.LargeImage, clientID, token) + if err != nil { + log.Printf("Failed to process image for user %s, continuing without image: %v", username, err) + // Clear the image and continue without it + data.Assets.LargeImage = "" + } else { + log.Printf("Processed image for URL %s: %s", data.Assets.LargeImage, processedImage) + data.Assets.LargeImage = processedImage + } + + presence := presencePayload{ + Activities: []activity{data}, + Status: "dnd", + Afk: false, + } + return r.sendMessage(ctx, username, presenceOpCode, presence) +} + +func (r *discordRPC) clearActivity(ctx context.Context, username string) error { + log.Printf("Clearing activity for user %s", username) + return r.sendMessage(ctx, username, presenceOpCode, presencePayload{}) +} + +func (r *discordRPC) sendMessage(ctx context.Context, username string, opCode int, payload any) error { + message := map[string]any{ + "op": opCode, + "d": payload, + } + b, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal presence update: %w", err) + } + + resp, _ := r.ws.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: username, + Message: string(b), + }) + if resp.Error != "" { + return fmt.Errorf("failed to send presence update: %s", resp.Error) + } + return nil +} + +func (r *discordRPC) getDiscordGateway(ctx context.Context) (string, error) { + resp, _ := r.web.Get(ctx, &http.HttpRequest{ + Url: "https://discord.com/api/gateway", + }) + if resp.Error != "" { + return "", fmt.Errorf("failed to get Discord gateway: %s", resp.Error) + } + var result map[string]string + err := json.Unmarshal(resp.Body, &result) + if err != nil { + return "", fmt.Errorf("failed to parse Discord gateway response: %w", err) + } + return result["url"], nil +} + +func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error { + resp, _ := r.mem.GetInt(ctx, &cache.GetRequest{ + Key: fmt.Sprintf("discord.seq.%s", username), + }) + log.Printf("Sending heartbeat for user %s: %d", username, resp.Value) + return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value) +} + +func (r *discordRPC) isConnected(ctx context.Context, username string) bool { + err := r.sendHeartbeat(ctx, username) + return err == nil +} + +func (r *discordRPC) connect(ctx context.Context, username string, token string) error { + if r.isConnected(ctx, username) { + log.Printf("Reusing existing connection for user %s", username) + return nil + } + log.Printf("Creating new connection for user %s", username) + + // Get Discord Gateway URL + gateway, err := r.getDiscordGateway(ctx) + if err != nil { + return fmt.Errorf("failed to get Discord gateway: %w", err) + } + log.Printf("Using gateway: %s", gateway) + + // Connect to Discord Gateway + resp, _ := r.ws.Connect(ctx, &websocket.ConnectRequest{ + ConnectionId: username, + Url: gateway, + }) + if resp.Error != "" { + return fmt.Errorf("failed to connect to WebSocket: %s", resp.Error) + } + + // Send identify payload + payload := identifyPayload{ + Token: token, + Intents: 0, + Properties: identifyProperties{ + OS: "Windows 10", + Browser: "Discord Client", + Device: "Discord Client", + }, + } + err = r.sendMessage(ctx, username, gateOpCode, payload) + if err != nil { + return fmt.Errorf("failed to send identify payload: %w", err) + } + + // Schedule heartbeats for this user/connection + cronResp, _ := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + CronExpression: fmt.Sprintf("@every %ds", heartbeatInterval), + ScheduleId: username, + }) + log.Printf("Scheduled heartbeat for user %s with ID %s", username, cronResp.ScheduleId) + + log.Printf("Successfully authenticated user %s", username) + return nil +} + +func (r *discordRPC) disconnect(ctx context.Context, username string) error { + if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" { + return fmt.Errorf("failed to cancel schedule: %s", resp.Error) + } + resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{ + ConnectionId: username, + Code: 1000, + Reason: "Navidrome disconnect", + }) + if resp.Error != "" { + return fmt.Errorf("failed to close WebSocket connection: %s", resp.Error) + } + return nil +} + +func (r *discordRPC) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) { + if len(req.Message) < 1024 { + log.Printf("Received WebSocket message for connection '%s': %s", req.ConnectionId, req.Message) + } else { + log.Printf("Received WebSocket message for connection '%s' (truncated): %s...", req.ConnectionId, req.Message[:1021]) + } + + // Parse the message. If it's a heartbeat_ack, store the sequence number. + message := map[string]any{} + err := json.Unmarshal([]byte(req.Message), &message) + if err != nil { + return nil, fmt.Errorf("failed to parse WebSocket message: %w", err) + } + if v := message["s"]; v != nil { + seq := int64(v.(float64)) + log.Printf("Received heartbeat_ack for connection '%s': %d", req.ConnectionId, seq) + resp, _ := r.mem.SetInt(ctx, &cache.SetIntRequest{ + Key: fmt.Sprintf("discord.seq.%s", req.ConnectionId), + Value: seq, + TtlSeconds: heartbeatInterval * 2, + }) + if !resp.Success { + return nil, fmt.Errorf("failed to store sequence number for user %s", req.ConnectionId) + } + } + return nil, nil +} + +func (r *discordRPC) OnBinaryMessage(_ context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) { + log.Printf("Received unexpected binary message for connection '%s'", req.ConnectionId) + return nil, nil +} + +func (r *discordRPC) OnError(_ context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) { + log.Printf("WebSocket error for connection '%s': %s", req.ConnectionId, req.Error) + return nil, nil +} + +func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) { + log.Printf("WebSocket connection '%s' closed with code %d: %s", req.ConnectionId, req.Code, req.Reason) + return nil, nil +} + +func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + return nil, r.sendHeartbeat(ctx, req.ScheduleId) +} diff --git a/plugins/examples/subsonicapi-demo/README.md b/plugins/examples/subsonicapi-demo/README.md new file mode 100644 index 000000000..b5ac9f784 --- /dev/null +++ b/plugins/examples/subsonicapi-demo/README.md @@ -0,0 +1,88 @@ +# SubsonicAPI Demo Plugin + +This example plugin demonstrates how to use the SubsonicAPI host service to access Navidrome's Subsonic API from within a plugin. + +## What it does + +The plugin performs the following operations during initialization: + +1. **Ping the server**: Calls `/rest/ping` to check if the Subsonic API is responding +2. **Get license info**: Calls `/rest/getLicense` to retrieve server license information + +## Key Features + +- Shows how to request `subsonicapi` permission in the manifest +- Demonstrates making Subsonic API calls using the `subsonicapi.Call()` method +- Handles both successful responses and errors +- Uses proper lifecycle management with `OnInit` + +## Usage + +### Manifest Configuration + +```json +{ + "permissions": { + "subsonicapi": { + "reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins", + "allowAdmins": true + } + } +} +``` + +### Plugin Implementation + +```go +import "github.com/navidrome/navidrome/plugins/host/subsonicapi" + +var subsonicService = subsonicapi.NewSubsonicAPIService() + +// OnInit is called when the plugin is loaded +func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + // Make API calls + response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + }) + // Handle response... +} +``` + +When running Navidrome with this plugin installed, it will automatically call the Subsonic API endpoints during the +server startup, and you can see the results in the logs: + +```agsl +INFO[0000] 2022/01/01 00:00:00 SubsonicAPI Demo Plugin initializing... +DEBU[0000] API: New request /ping client=subsonicapi-demo username=admin version=1.16.1 +DEBU[0000] API: Successful response endpoint=/ping status=OK +DEBU[0000] API: New request /getLicense client=subsonicapi-demo username=admin version=1.16.1 +INFO[0000] 2022/01/01 00:00:00 SubsonicAPI ping response: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true}} +DEBU[0000] API: Successful response endpoint=/getLicense status=OK +DEBU[0000] Plugin initialized successfully elapsed=41.9ms plugin=subsonicapi-demo +INFO[0000] 2022/01/01 00:00:00 SubsonicAPI license info: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true,"license":{"valid":true}}} +``` + +## Important Notes + +1. **Authentication**: The plugin must provide valid authentication parameters in the URL: + - **Required**: `u` (username) - The service validates this parameter is present + - Example: `"/rest/ping?u=admin"` +2. **URL Format**: Only the path and query parameters from the URL are used - host, protocol, and method are ignored +3. **Automatic Parameters**: The service automatically adds: + - `c`: Plugin name (client identifier) + - `v`: Subsonic API version (1.16.1) + - `f`: Response format (json) +4. **Internal Authentication**: The service sets up internal authentication using the `u` parameter +5. **Lifecycle**: This plugin uses `LifecycleManagement` with only the `OnInit` method + +## Building + +This plugin uses the `wasip1` build constraint and must be compiled for WebAssembly: + +```bash +# Using the project's make target (recommended) +make plugin-examples + +# Manual compilation (when using the proper toolchain) +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go +``` diff --git a/plugins/examples/subsonicapi-demo/manifest.json b/plugins/examples/subsonicapi-demo/manifest.json new file mode 100644 index 000000000..d26c33181 --- /dev/null +++ b/plugins/examples/subsonicapi-demo/manifest.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "subsonicapi-demo", + "author": "Navidrome Team", + "version": "1.0.0", + "description": "Example plugin demonstrating SubsonicAPI host service usage", + "website": "https://github.com/navidrome/navidrome", + "capabilities": ["LifecycleManagement"], + "permissions": { + "subsonicapi": { + "reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins", + "allowAdmins": true, + "allowedUsernames": ["admin"] + } + } +} diff --git a/plugins/examples/subsonicapi-demo/plugin.go b/plugins/examples/subsonicapi-demo/plugin.go new file mode 100644 index 000000000..c3adc6579 --- /dev/null +++ b/plugins/examples/subsonicapi-demo/plugin.go @@ -0,0 +1,64 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" +) + +// SubsonicAPIService instance for making API calls +var subsonicService = subsonicapi.NewSubsonicAPIService() + +// SubsonicAPIDemoPlugin implements LifecycleManagement interface +type SubsonicAPIDemoPlugin struct{} + +// OnInit is called when the plugin is loaded +func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("SubsonicAPI Demo Plugin initializing...") + + // Example: Call the ping endpoint to check if the server is alive + response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + }) + + if err != nil { + log.Printf("SubsonicAPI call failed: %v", err) + return &api.InitResponse{Error: err.Error()}, nil + } + + if response.Error != "" { + log.Printf("SubsonicAPI returned error: %s", response.Error) + return &api.InitResponse{Error: response.Error}, nil + } + + log.Printf("SubsonicAPI ping response: %s", response.Json) + + // Example: Get server info + infoResponse, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/getLicense?u=admin", + }) + + if err != nil { + log.Printf("SubsonicAPI getLicense call failed: %v", err) + return &api.InitResponse{Error: err.Error()}, nil + } + + if infoResponse.Error != "" { + log.Printf("SubsonicAPI getLicense returned error: %s", infoResponse.Error) + return &api.InitResponse{Error: infoResponse.Error}, nil + } + + log.Printf("SubsonicAPI license info: %s", infoResponse.Json) + + return &api.InitResponse{}, nil +} + +func main() {} + +func init() { + api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{}) +} diff --git a/plugins/examples/wikimedia/README.md b/plugins/examples/wikimedia/README.md new file mode 100644 index 000000000..15feed2d3 --- /dev/null +++ b/plugins/examples/wikimedia/README.md @@ -0,0 +1,32 @@ +# Wikimedia Artist Metadata Plugin + +This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint. + +## Implemented Methods + +- `GetArtistBiography`: Returns the artist's English biography/description from Wikidata. +- `GetArtistURL`: Returns the artist's official website (if available) from Wikidata. +- `GetArtistImages`: Returns the artist's main image (Wikimedia Commons) from Wikidata. + +All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia. + +## How it Works + +- The plugin uses the host-provided HTTP service (`HttpService`) to make SPARQL queries to the Wikidata endpoint. +- No network requests are made directly from the plugin; all HTTP is routed through the host. + +## Building + +To build the plugin to WASM: + +``` +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go +``` + +## Usage + +Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory. + +--- + +For more details, see the source code in `plugin.go`. diff --git a/plugins/examples/wikimedia/manifest.json b/plugins/examples/wikimedia/manifest.json new file mode 100644 index 000000000..5d0196e0a --- /dev/null +++ b/plugins/examples/wikimedia/manifest.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "wikimedia", + "author": "Navidrome", + "version": "1.0.0", + "description": "Artist information and images from Wikimedia Commons", + "website": "https://commons.wikimedia.org", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch artist information and images from Wikimedia Commons API", + "allowedUrls": { + "https://*.wikimedia.org": ["GET"], + "https://*.wikipedia.org": ["GET"], + "https://commons.wikimedia.org": ["GET"] + }, + "allowLocalNetwork": false + } + } +} diff --git a/plugins/examples/wikimedia/plugin.go b/plugins/examples/wikimedia/plugin.go new file mode 100644 index 000000000..b64e8cd86 --- /dev/null +++ b/plugins/examples/wikimedia/plugin.go @@ -0,0 +1,387 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/http" +) + +const ( + wikidataEndpoint = "https://query.wikidata.org/sparql" + dbpediaEndpoint = "https://dbpedia.org/sparql" + mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php" + requestTimeoutMs = 5000 +) + +var ( + ErrNotFound = api.ErrNotFound + ErrNotImplemented = api.ErrNotImplemented + + client = http.NewHttpService() +) + +// SPARQLResult struct for all possible fields +// Only the needed field will be non-nil in each context +// (Sitelink, Wiki, Comment, Img) +type SPARQLResult struct { + Results struct { + Bindings []struct { + Sitelink *struct{ Value string } `json:"sitelink,omitempty"` + Wiki *struct{ Value string } `json:"wiki,omitempty"` + Comment *struct{ Value string } `json:"comment,omitempty"` + Img *struct{ Value string } `json:"img,omitempty"` + } `json:"bindings"` + } `json:"results"` +} + +// MediaWikiExtractResult is used to unmarshal MediaWiki API extract responses +// (for getWikipediaExtract) +type MediaWikiExtractResult struct { + Query struct { + Pages map[string]struct { + PageID int `json:"pageid"` + Ns int `json:"ns"` + Title string `json:"title"` + Extract string `json:"extract"` + Missing bool `json:"missing"` + } `json:"pages"` + } `json:"query"` +} + +// --- SPARQL Query Helper --- +func sparqlQuery(ctx context.Context, client http.HttpService, endpoint, query string) (*SPARQLResult, error) { + form := url.Values{} + form.Set("query", query) + + req := &http.HttpRequest{ + Url: endpoint, + Headers: map[string]string{ + "Accept": "application/sparql-results+json", + "Content-Type": "application/x-www-form-urlencoded", // Required by SPARQL endpoints + "User-Agent": "NavidromeWikimediaPlugin/0.1", + }, + Body: []byte(form.Encode()), // Send encoded form data + TimeoutMs: requestTimeoutMs, + } + log.Printf("[Wikimedia Query] Attempting SPARQL query to %s (query length: %d):\n%s", endpoint, len(query), query) + resp, err := client.Post(ctx, req) + if err != nil { + return nil, fmt.Errorf("SPARQL request error: %w", err) + } + if resp.Status != 200 { + log.Printf("[Wikimedia Query] SPARQL HTTP error %d for query to %s. Body: %s", resp.Status, endpoint, string(resp.Body)) + return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status) + } + var result SPARQLResult + if err := json.Unmarshal(resp.Body, &result); err != nil { + return nil, fmt.Errorf("failed to parse SPARQL response: %w", err) + } + if len(result.Results.Bindings) == 0 { + return nil, ErrNotFound + } + return &result, nil +} + +// --- MediaWiki API Helper --- +func mediawikiQuery(ctx context.Context, client http.HttpService, params url.Values) ([]byte, error) { + apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode()) + req := &http.HttpRequest{ + Url: apiURL, + Headers: map[string]string{ + "Accept": "application/json", + "User-Agent": "NavidromeWikimediaPlugin/0.1", + }, + TimeoutMs: requestTimeoutMs, + } + resp, err := client.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("MediaWiki request error: %w", err) + } + if resp.Status != 200 { + return nil, fmt.Errorf("MediaWiki HTTP error: status %d, body: %s", resp.Status, string(resp.Body)) + } + return resp.Body, nil +} + +// --- Wikidata Fetch Functions --- +func getWikidataWikipediaURL(ctx context.Context, client http.HttpService, mbid, name string) (string, error) { + var q string + if mbid != "" { + // Using property chain: ?sitelink schema:about ?artist; schema:isPartOf . + q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist wdt:P434 "%s". ?sitelink schema:about ?artist; schema:isPartOf . } LIMIT 1`, mbid) + } else if name != "" { + escapedName := strings.ReplaceAll(name, "\"", "\\\"") + // Using property chain: ?sitelink schema:about ?artist; schema:isPartOf . + q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist rdfs:label "%s"@en. ?sitelink schema:about ?artist; schema:isPartOf . } LIMIT 1`, escapedName) + } else { + return "", errors.New("MBID or Name required for Wikidata URL lookup") + } + + result, err := sparqlQuery(ctx, client, wikidataEndpoint, q) + if err != nil { + return "", fmt.Errorf("Wikidata SPARQL query failed: %w", err) + } + if result.Results.Bindings[0].Sitelink != nil { + return result.Results.Bindings[0].Sitelink.Value, nil + } + return "", ErrNotFound +} + +// --- DBpedia Fetch Functions --- +func getDBpediaWikipediaURL(ctx context.Context, client http.HttpService, name string) (string, error) { + if name == "" { + return "", ErrNotFound + } + escapedName := strings.ReplaceAll(name, "\"", "\\\"") + q := fmt.Sprintf(`SELECT ?wiki WHERE { ?artist foaf:name "%s"@en; foaf:isPrimaryTopicOf ?wiki. FILTER regex(str(?wiki), "^https://en.wikipedia.org/") } LIMIT 1`, escapedName) + result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q) + if err != nil { + return "", fmt.Errorf("DBpedia SPARQL query failed: %w", err) + } + if result.Results.Bindings[0].Wiki != nil { + return result.Results.Bindings[0].Wiki.Value, nil + } + return "", ErrNotFound +} + +func getDBpediaComment(ctx context.Context, client http.HttpService, name string) (string, error) { + if name == "" { + return "", ErrNotFound + } + escapedName := strings.ReplaceAll(name, "\"", "\\\"") + q := fmt.Sprintf(`SELECT ?comment WHERE { ?artist foaf:name "%s"@en; rdfs:comment ?comment. FILTER (lang(?comment) = 'en') } LIMIT 1`, escapedName) + result, err := sparqlQuery(ctx, client, dbpediaEndpoint, q) + if err != nil { + return "", fmt.Errorf("DBpedia comment SPARQL query failed: %w", err) + } + if result.Results.Bindings[0].Comment != nil { + return result.Results.Bindings[0].Comment.Value, nil + } + return "", ErrNotFound +} + +// --- Wikipedia API Fetch Function --- +func getWikipediaExtract(ctx context.Context, client http.HttpService, pageTitle string) (string, error) { + if pageTitle == "" { + return "", errors.New("page title required for Wikipedia API lookup") + } + params := url.Values{} + params.Set("action", "query") + params.Set("format", "json") + params.Set("prop", "extracts") + params.Set("exintro", "true") // Intro section only + params.Set("explaintext", "true") // Plain text + params.Set("titles", pageTitle) + params.Set("redirects", "1") // Follow redirects + + body, err := mediawikiQuery(ctx, client, params) + if err != nil { + return "", fmt.Errorf("MediaWiki query failed: %w", err) + } + + var result MediaWikiExtractResult + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("failed to parse MediaWiki response: %w", err) + } + + // Iterate through the pages map (usually only one page) + for _, page := range result.Query.Pages { + if page.Missing { + continue // Skip missing pages + } + if page.Extract != "" { + return strings.TrimSpace(page.Extract), nil + } + } + + return "", ErrNotFound +} + +// --- Helper to get Wikipedia Page Title from URL --- +func extractPageTitleFromURL(wikiURL string) (string, error) { + parsedURL, err := url.Parse(wikiURL) + if err != nil { + return "", err + } + if parsedURL.Host != "en.wikipedia.org" { + return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host) + } + pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/") + if len(pathParts) < 2 || pathParts[0] != "wiki" { + return "", fmt.Errorf("URL path does not match /wiki/ format: %s", parsedURL.Path) + } + title := pathParts[1] + if title == "" { + return "", errors.New("extracted title is empty") + } + decodedTitle, err := url.PathUnescape(title) + if err != nil { + return "", fmt.Errorf("failed to decode title '%s': %w", title, err) + } + return decodedTitle, nil +} + +// --- Agent Implementation --- +type WikimediaAgent struct{} + +// GetArtistURL fetches the Wikipedia URL. +// Order: Wikidata(MBID/Name) -> DBpedia(Name) -> Search URL +func (WikimediaAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + var wikiURL string + var err error + + // 1. Try Wikidata (MBID first, then name) + wikiURL, err = getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name) + if err == nil && wikiURL != "" { + return &api.ArtistURLResponse{Url: wikiURL}, nil + } + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia] Error fetching Wikidata URL: %v\n", err) + // Don't stop, try DBpedia + } + + // 2. Try DBpedia (Name only) + if req.Name != "" { + wikiURL, err = getDBpediaWikipediaURL(ctx, client, req.Name) + if err == nil && wikiURL != "" { + return &api.ArtistURLResponse{Url: wikiURL}, nil + } + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia] Error fetching DBpedia URL: %v\n", err) + // Don't stop, generate search URL + } + } + + // 3. Fallback to search URL + if req.Name != "" { + searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(req.Name)) + log.Printf("[Wikimedia] URL not found, falling back to search URL: %s\n", searchURL) + return &api.ArtistURLResponse{Url: searchURL}, nil + } + + log.Printf("[Wikimedia] Could not determine Wikipedia URL for: %s (%s)\n", req.Name, req.Mbid) + return nil, ErrNotFound +} + +// GetArtistBiography fetches the long biography. +// Order: Wikipedia API (via Wikidata/DBpedia URL) -> DBpedia Comment (Name) +func (WikimediaAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + var bio string + var err error + + log.Printf("[Wikimedia Bio] Fetching for Name: %s, MBID: %s", req.Name, req.Mbid) + + // 1. Get Wikipedia URL (using the logic from GetArtistURL) + wikiURL := "" + // Try Wikidata first + tempURL, wdErr := getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name) + if wdErr == nil && tempURL != "" { + log.Printf("[Wikimedia Bio] Found Wikidata URL: %s", tempURL) + wikiURL = tempURL + } else if req.Name != "" { + // Try DBpedia if Wikidata failed or returned not found + log.Printf("[Wikimedia Bio] Wikidata URL failed (%v), trying DBpedia URL", wdErr) + tempURL, dbErr := getDBpediaWikipediaURL(ctx, client, req.Name) + if dbErr == nil && tempURL != "" { + log.Printf("[Wikimedia Bio] Found DBpedia URL: %s", tempURL) + wikiURL = tempURL + } else { + log.Printf("[Wikimedia Bio] DBpedia URL failed (%v)", dbErr) + } + } + + // 2. If Wikipedia URL found, try MediaWiki API + if wikiURL != "" { + pageTitle, err := extractPageTitleFromURL(wikiURL) + if err == nil { + log.Printf("[Wikimedia Bio] Extracted page title: %s", pageTitle) + bio, err = getWikipediaExtract(ctx, client, pageTitle) + if err == nil && bio != "" { + log.Printf("[Wikimedia Bio] Found Wikipedia extract.") + return &api.ArtistBiographyResponse{Biography: bio}, nil + } + log.Printf("[Wikimedia Bio] Wikipedia extract failed: %v", err) + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia Bio] Error fetching Wikipedia extract for '%s': %v", pageTitle, err) + // Don't stop, try DBpedia comment + } + } else { + log.Printf("[Wikimedia Bio] Error extracting page title from URL '%s': %v", wikiURL, err) + // Don't stop, try DBpedia comment + } + } + + // 3. Fallback to DBpedia Comment (Name only) + if req.Name != "" { + log.Printf("[Wikimedia Bio] Falling back to DBpedia comment for name: %s", req.Name) + bio, err = getDBpediaComment(ctx, client, req.Name) + if err == nil && bio != "" { + log.Printf("[Wikimedia Bio] Found DBpedia comment.") + return &api.ArtistBiographyResponse{Biography: bio}, nil + } + log.Printf("[Wikimedia Bio] DBpedia comment failed: %v", err) + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia Bio] Error fetching DBpedia comment for '%s': %v", req.Name, err) + } + } + + log.Printf("[Wikimedia Bio] Final: Biography not found for: %s (%s)", req.Name, req.Mbid) + return nil, ErrNotFound +} + +// GetArtistImages fetches images (Wikidata only for now) +func (WikimediaAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + var q string + if req.Mbid != "" { + q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, req.Mbid) + } else if req.Name != "" { + escapedName := strings.ReplaceAll(req.Name, "\"", "\\\"") + q = fmt.Sprintf(`SELECT ?img WHERE { ?artist rdfs:label "%s"@en; wdt:P18 ?img } LIMIT 1`, escapedName) + } else { + return nil, errors.New("MBID or Name required for Wikidata Image lookup") + } + + result, err := sparqlQuery(ctx, client, wikidataEndpoint, q) + if err != nil { + log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid) + return nil, ErrNotFound + } + if result.Results.Bindings[0].Img != nil { + return &api.ArtistImageResponse{Images: []*api.ExternalImage{{Url: result.Results.Bindings[0].Img.Value, Size: 0}}}, nil + } + log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid) + return nil, ErrNotFound +} + +// Not implemented methods +func (WikimediaAgent) GetArtistMBID(context.Context, *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, ErrNotImplemented +} +func (WikimediaAgent) GetSimilarArtists(context.Context, *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, ErrNotImplemented +} +func (WikimediaAgent) GetArtistTopSongs(context.Context, *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, ErrNotImplemented +} +func (WikimediaAgent) GetAlbumInfo(context.Context, *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + return nil, ErrNotImplemented +} + +func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return nil, ErrNotImplemented +} + +func main() {} + +func init() { + api.RegisterMetadataAgent(WikimediaAgent{}) +} diff --git a/plugins/host/artwork/artwork.pb.go b/plugins/host/artwork/artwork.pb.go new file mode 100644 index 000000000..228eced22 --- /dev/null +++ b/plugins/host/artwork/artwork.pb.go @@ -0,0 +1,73 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/artwork/artwork.proto + +package artwork + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetArtworkUrlRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Optional, 0 means original size +} + +func (x *GetArtworkUrlRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetArtworkUrlRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetArtworkUrlRequest) GetSize() int32 { + if x != nil { + return x.Size + } + return 0 +} + +type GetArtworkUrlResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *GetArtworkUrlResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetArtworkUrlResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +// go:plugin type=host version=1 +type ArtworkService interface { + GetArtistUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) + GetAlbumUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) + GetTrackUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) +} diff --git a/plugins/host/artwork/artwork.proto b/plugins/host/artwork/artwork.proto new file mode 100644 index 000000000..cb562e536 --- /dev/null +++ b/plugins/host/artwork/artwork.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package artwork; + +option go_package = "github.com/navidrome/navidrome/plugins/host/artwork;artwork"; + +// go:plugin type=host version=1 +service ArtworkService { + rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); +} + +message GetArtworkUrlRequest { + string id = 1; + int32 size = 2; // Optional, 0 means original size +} + +message GetArtworkUrlResponse { + string url = 1; +} \ No newline at end of file diff --git a/plugins/host/artwork/artwork_host.pb.go b/plugins/host/artwork/artwork_host.pb.go new file mode 100644 index 000000000..346fe1449 --- /dev/null +++ b/plugins/host/artwork/artwork_host.pb.go @@ -0,0 +1,130 @@ +//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: host/artwork/artwork.proto + +package artwork + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _artworkService struct { + ArtworkService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ArtworkService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _artworkService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetArtistUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_artist_url") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetAlbumUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_album_url") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetTrackUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_track_url") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _artworkService) _GetArtistUrl(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetArtworkUrlRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetArtistUrl(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _artworkService) _GetAlbumUrl(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetArtworkUrlRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetAlbumUrl(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _artworkService) _GetTrackUrl(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetArtworkUrlRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetTrackUrl(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/artwork/artwork_plugin.pb.go b/plugins/host/artwork/artwork_plugin.pb.go new file mode 100644 index 000000000..f54aac0b9 --- /dev/null +++ b/plugins/host/artwork/artwork_plugin.pb.go @@ -0,0 +1,90 @@ +//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: host/artwork/artwork.proto + +package artwork + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type artworkService struct{} + +func NewArtworkService() ArtworkService { + return artworkService{} +} + +//go:wasmimport env get_artist_url +func _get_artist_url(ptr uint32, size uint32) uint64 + +func (h artworkService) GetArtistUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_artist_url(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetArtworkUrlResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_album_url +func _get_album_url(ptr uint32, size uint32) uint64 + +func (h artworkService) GetAlbumUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_album_url(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetArtworkUrlResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_track_url +func _get_track_url(ptr uint32, size uint32) uint64 + +func (h artworkService) GetTrackUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_track_url(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetArtworkUrlResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/artwork/artwork_plugin_dev.go b/plugins/host/artwork/artwork_plugin_dev.go new file mode 100644 index 000000000..0071f5726 --- /dev/null +++ b/plugins/host/artwork/artwork_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package artwork + +func NewArtworkService() ArtworkService { + panic("not implemented") +} diff --git a/plugins/host/artwork/artwork_vtproto.pb.go b/plugins/host/artwork/artwork_vtproto.pb.go new file mode 100644 index 000000000..6a1c0ba4e --- /dev/null +++ b/plugins/host/artwork/artwork_vtproto.pb.go @@ -0,0 +1,425 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/artwork/artwork.proto + +package artwork + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *GetArtworkUrlRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetArtworkUrlRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetArtworkUrlRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Size != 0 { + i = encodeVarint(dAtA, i, uint64(m.Size)) + i-- + dAtA[i] = 0x10 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *GetArtworkUrlResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetArtworkUrlResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetArtworkUrlResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *GetArtworkUrlRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Size != 0 { + n += 1 + sov(uint64(m.Size)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetArtworkUrlResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *GetArtworkUrlRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetArtworkUrlRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetArtworkUrlRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType) + } + m.Size = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Size |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetArtworkUrlResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetArtworkUrlResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetArtworkUrlResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/cache/cache.pb.go b/plugins/host/cache/cache.pb.go new file mode 100644 index 000000000..6113a89b4 --- /dev/null +++ b/plugins/host/cache/cache.pb.go @@ -0,0 +1,420 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/cache/cache.proto + +package cache + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Request to store a string value +type SetStringRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // String value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetStringRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetStringRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetStringRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *SetStringRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Request to store an integer value +type SetIntRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // Integer value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetIntRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetIntRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetIntRequest) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *SetIntRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Request to store a float value +type SetFloatRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Float value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetFloatRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetFloatRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetFloatRequest) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *SetFloatRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Request to store a byte slice value +type SetBytesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // Byte slice value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetBytesRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetBytesRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetBytesRequest) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +func (x *SetBytesRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Response after setting a value +type SetResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful +} + +func (x *SetResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +// Request to get a value +type GetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key +} + +func (x *GetRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +// Response containing a string value +type GetStringResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The string value (if exists is true) +} + +func (x *GetStringResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetStringResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetStringResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +// Response containing an integer value +type GetIntResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // The integer value (if exists is true) +} + +func (x *GetIntResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetIntResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetIntResponse) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +// Response containing a float value +type GetFloatResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // The float value (if exists is true) +} + +func (x *GetFloatResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetFloatResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetFloatResponse) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +// Response containing a byte slice value +type GetBytesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The byte slice value (if exists is true) +} + +func (x *GetBytesResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetBytesResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetBytesResponse) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +// Request to remove a value +type RemoveRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key +} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *RemoveRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +// Response after removing a value +type RemoveResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful +} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *RemoveResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +// Request to check if a key exists +type HasRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key +} + +func (x *HasRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HasRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +// Response indicating if a key exists +type HasResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists +} + +func (x *HasResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HasResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +// go:plugin type=host version=1 +type CacheService interface { + // Set a string value in the cache + SetString(context.Context, *SetStringRequest) (*SetResponse, error) + // Get a string value from the cache + GetString(context.Context, *GetRequest) (*GetStringResponse, error) + // Set an integer value in the cache + SetInt(context.Context, *SetIntRequest) (*SetResponse, error) + // Get an integer value from the cache + GetInt(context.Context, *GetRequest) (*GetIntResponse, error) + // Set a float value in the cache + SetFloat(context.Context, *SetFloatRequest) (*SetResponse, error) + // Get a float value from the cache + GetFloat(context.Context, *GetRequest) (*GetFloatResponse, error) + // Set a byte slice value in the cache + SetBytes(context.Context, *SetBytesRequest) (*SetResponse, error) + // Get a byte slice value from the cache + GetBytes(context.Context, *GetRequest) (*GetBytesResponse, error) + // Remove a value from the cache + Remove(context.Context, *RemoveRequest) (*RemoveResponse, error) + // Check if a key exists in the cache + Has(context.Context, *HasRequest) (*HasResponse, error) +} diff --git a/plugins/host/cache/cache.proto b/plugins/host/cache/cache.proto new file mode 100644 index 000000000..8081eca3d --- /dev/null +++ b/plugins/host/cache/cache.proto @@ -0,0 +1,120 @@ +syntax = "proto3"; + +package cache; + +option go_package = "github.com/navidrome/navidrome/plugins/host/cache;cache"; + +// go:plugin type=host version=1 +service CacheService { + // Set a string value in the cache + rpc SetString(SetStringRequest) returns (SetResponse); + + // Get a string value from the cache + rpc GetString(GetRequest) returns (GetStringResponse); + + // Set an integer value in the cache + rpc SetInt(SetIntRequest) returns (SetResponse); + + // Get an integer value from the cache + rpc GetInt(GetRequest) returns (GetIntResponse); + + // Set a float value in the cache + rpc SetFloat(SetFloatRequest) returns (SetResponse); + + // Get a float value from the cache + rpc GetFloat(GetRequest) returns (GetFloatResponse); + + // Set a byte slice value in the cache + rpc SetBytes(SetBytesRequest) returns (SetResponse); + + // Get a byte slice value from the cache + rpc GetBytes(GetRequest) returns (GetBytesResponse); + + // Remove a value from the cache + rpc Remove(RemoveRequest) returns (RemoveResponse); + + // Check if a key exists in the cache + rpc Has(HasRequest) returns (HasResponse); +} + +// Request to store a string value +message SetStringRequest { + string key = 1; // Cache key + string value = 2; // String value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Request to store an integer value +message SetIntRequest { + string key = 1; // Cache key + int64 value = 2; // Integer value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Request to store a float value +message SetFloatRequest { + string key = 1; // Cache key + double value = 2; // Float value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Request to store a byte slice value +message SetBytesRequest { + string key = 1; // Cache key + bytes value = 2; // Byte slice value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Response after setting a value +message SetResponse { + bool success = 1; // Whether the operation was successful +} + +// Request to get a value +message GetRequest { + string key = 1; // Cache key +} + +// Response containing a string value +message GetStringResponse { + bool exists = 1; // Whether the key exists + string value = 2; // The string value (if exists is true) +} + +// Response containing an integer value +message GetIntResponse { + bool exists = 1; // Whether the key exists + int64 value = 2; // The integer value (if exists is true) +} + +// Response containing a float value +message GetFloatResponse { + bool exists = 1; // Whether the key exists + double value = 2; // The float value (if exists is true) +} + +// Response containing a byte slice value +message GetBytesResponse { + bool exists = 1; // Whether the key exists + bytes value = 2; // The byte slice value (if exists is true) +} + +// Request to remove a value +message RemoveRequest { + string key = 1; // Cache key +} + +// Response after removing a value +message RemoveResponse { + bool success = 1; // Whether the operation was successful +} + +// Request to check if a key exists +message HasRequest { + string key = 1; // Cache key +} + +// Response indicating if a key exists +message HasResponse { + bool exists = 1; // Whether the key exists +} \ No newline at end of file diff --git a/plugins/host/cache/cache_host.pb.go b/plugins/host/cache/cache_host.pb.go new file mode 100644 index 000000000..479473fa8 --- /dev/null +++ b/plugins/host/cache/cache_host.pb.go @@ -0,0 +1,374 @@ +//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: host/cache/cache.proto + +package cache + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _cacheService struct { + CacheService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions CacheService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _cacheService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetString), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_string") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetString), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_string") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_int") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_int") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_float") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_float") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_bytes") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_bytes") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Remove), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("remove") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Has), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("has") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +// Set a string value in the cache + +func (h _cacheService) _SetString(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetStringRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetString(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get a string value from the cache + +func (h _cacheService) _GetString(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetString(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Set an integer value in the cache + +func (h _cacheService) _SetInt(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetIntRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetInt(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get an integer value from the cache + +func (h _cacheService) _GetInt(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetInt(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Set a float value in the cache + +func (h _cacheService) _SetFloat(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetFloatRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetFloat(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get a float value from the cache + +func (h _cacheService) _GetFloat(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetFloat(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Set a byte slice value in the cache + +func (h _cacheService) _SetBytes(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetBytesRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetBytes(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get a byte slice value from the cache + +func (h _cacheService) _GetBytes(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetBytes(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Remove a value from the cache + +func (h _cacheService) _Remove(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(RemoveRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Remove(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Check if a key exists in the cache + +func (h _cacheService) _Has(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HasRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Has(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/cache/cache_plugin.pb.go b/plugins/host/cache/cache_plugin.pb.go new file mode 100644 index 000000000..6e3bdcd44 --- /dev/null +++ b/plugins/host/cache/cache_plugin.pb.go @@ -0,0 +1,251 @@ +//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: host/cache/cache.proto + +package cache + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type cacheService struct{} + +func NewCacheService() CacheService { + return cacheService{} +} + +//go:wasmimport env set_string +func _set_string(ptr uint32, size uint32) uint64 + +func (h cacheService) SetString(ctx context.Context, request *SetStringRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_string(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_string +func _get_string(ptr uint32, size uint32) uint64 + +func (h cacheService) GetString(ctx context.Context, request *GetRequest) (*GetStringResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_string(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetStringResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env set_int +func _set_int(ptr uint32, size uint32) uint64 + +func (h cacheService) SetInt(ctx context.Context, request *SetIntRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_int(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_int +func _get_int(ptr uint32, size uint32) uint64 + +func (h cacheService) GetInt(ctx context.Context, request *GetRequest) (*GetIntResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_int(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetIntResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env set_float +func _set_float(ptr uint32, size uint32) uint64 + +func (h cacheService) SetFloat(ctx context.Context, request *SetFloatRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_float(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_float +func _get_float(ptr uint32, size uint32) uint64 + +func (h cacheService) GetFloat(ctx context.Context, request *GetRequest) (*GetFloatResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_float(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetFloatResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env set_bytes +func _set_bytes(ptr uint32, size uint32) uint64 + +func (h cacheService) SetBytes(ctx context.Context, request *SetBytesRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_bytes(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_bytes +func _get_bytes(ptr uint32, size uint32) uint64 + +func (h cacheService) GetBytes(ctx context.Context, request *GetRequest) (*GetBytesResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_bytes(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetBytesResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env remove +func _remove(ptr uint32, size uint32) uint64 + +func (h cacheService) Remove(ctx context.Context, request *RemoveRequest) (*RemoveResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _remove(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(RemoveResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env has +func _has(ptr uint32, size uint32) uint64 + +func (h cacheService) Has(ctx context.Context, request *HasRequest) (*HasResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _has(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HasResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/cache/cache_plugin_dev.go b/plugins/host/cache/cache_plugin_dev.go new file mode 100644 index 000000000..824dcc71d --- /dev/null +++ b/plugins/host/cache/cache_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package cache + +func NewCacheService() CacheService { + panic("not implemented") +} diff --git a/plugins/host/cache/cache_vtproto.pb.go b/plugins/host/cache/cache_vtproto.pb.go new file mode 100644 index 000000000..0ee3d9f22 --- /dev/null +++ b/plugins/host/cache/cache_vtproto.pb.go @@ -0,0 +1,2352 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/cache/cache.proto + +package cache + +import ( + binary "encoding/binary" + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + math "math" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *SetStringRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetStringRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetStringRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetIntRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetIntRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetIntRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if m.Value != 0 { + i = encodeVarint(dAtA, i, uint64(m.Value)) + i-- + dAtA[i] = 0x10 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetFloatRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetFloatRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetFloatRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if m.Value != 0 { + i -= 8 + binary.LittleEndian.PutUint64(dAtA[i:], uint64(math.Float64bits(float64(m.Value)))) + i-- + dAtA[i] = 0x11 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetBytesRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetBytesRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetBytesRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Success { + i-- + if m.Success { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *GetStringResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetStringResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetStringResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetIntResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetIntResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetIntResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Value != 0 { + i = encodeVarint(dAtA, i, uint64(m.Value)) + i-- + dAtA[i] = 0x10 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetFloatResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetFloatResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetFloatResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Value != 0 { + i -= 8 + binary.LittleEndian.PutUint64(dAtA[i:], uint64(math.Float64bits(float64(m.Value)))) + i-- + dAtA[i] = 0x11 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetBytesResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetBytesResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetBytesResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *RemoveRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *RemoveRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *RemoveRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *RemoveResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *RemoveResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *RemoveResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Success { + i-- + if m.Success { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *HasRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HasRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HasRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *HasResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HasResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HasResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *SetStringRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetIntRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Value != 0 { + n += 1 + sov(uint64(m.Value)) + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetFloatRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Value != 0 { + n += 9 + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetBytesRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Success { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func (m *GetRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetStringResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetIntResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + if m.Value != 0 { + n += 1 + sov(uint64(m.Value)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetFloatResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + if m.Value != 0 { + n += 9 + } + n += len(m.unknownFields) + return n +} + +func (m *GetBytesResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *RemoveRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *RemoveResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Success { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func (m *HasRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *HasResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *SetStringRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetStringRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetStringRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetIntRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetIntRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetIntRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + m.Value = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Value |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetFloatRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetFloatRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetFloatRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 1 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var v uint64 + if (iNdEx + 8) > l { + return io.ErrUnexpectedEOF + } + v = uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:])) + iNdEx += 8 + m.Value = float64(math.Float64frombits(v)) + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetBytesRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetBytesRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetBytesRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = append(m.Value[:0], dAtA[iNdEx:postIndex]...) + if m.Value == nil { + m.Value = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Success", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Success = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetStringResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetStringResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetStringResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetIntResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetIntResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetIntResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + m.Value = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Value |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetFloatResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetFloatResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetFloatResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 1 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var v uint64 + if (iNdEx + 8) > l { + return io.ErrUnexpectedEOF + } + v = uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:])) + iNdEx += 8 + m.Value = float64(math.Float64frombits(v)) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetBytesResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetBytesResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetBytesResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = append(m.Value[:0], dAtA[iNdEx:postIndex]...) + if m.Value == nil { + m.Value = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *RemoveRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: RemoveRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: RemoveRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *RemoveResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: RemoveResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: RemoveResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Success", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Success = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *HasRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HasRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HasRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *HasResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HasResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HasResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/config/config.pb.go b/plugins/host/config/config.pb.go new file mode 100644 index 000000000..dfc70af19 --- /dev/null +++ b/plugins/host/config/config.pb.go @@ -0,0 +1,54 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/config/config.proto + +package config + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetPluginConfigRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetPluginConfigRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type GetPluginConfigResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *GetPluginConfigResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetPluginConfigResponse) GetConfig() map[string]string { + if x != nil { + return x.Config + } + return nil +} + +// go:plugin type=host version=1 +type ConfigService interface { + GetPluginConfig(context.Context, *GetPluginConfigRequest) (*GetPluginConfigResponse, error) +} diff --git a/plugins/host/config/config.proto b/plugins/host/config/config.proto new file mode 100644 index 000000000..76076b47b --- /dev/null +++ b/plugins/host/config/config.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package config; + +option go_package = "github.com/navidrome/navidrome/plugins/host/config;config"; + +// go:plugin type=host version=1 +service ConfigService { + rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse); +} + +message GetPluginConfigRequest { + // No fields needed; plugin name is inferred from context +} + +message GetPluginConfigResponse { + map<string, string> config = 1; +} \ No newline at end of file diff --git a/plugins/host/config/config_host.pb.go b/plugins/host/config/config_host.pb.go new file mode 100644 index 000000000..87894f1a2 --- /dev/null +++ b/plugins/host/config/config_host.pb.go @@ -0,0 +1,66 @@ +//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: host/config/config.proto + +package config + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _configService struct { + ConfigService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ConfigService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _configService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetPluginConfig), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_plugin_config") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _configService) _GetPluginConfig(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetPluginConfigRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetPluginConfig(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/config/config_plugin.pb.go b/plugins/host/config/config_plugin.pb.go new file mode 100644 index 000000000..45c60d13a --- /dev/null +++ b/plugins/host/config/config_plugin.pb.go @@ -0,0 +1,44 @@ +//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: host/config/config.proto + +package config + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type configService struct{} + +func NewConfigService() ConfigService { + return configService{} +} + +//go:wasmimport env get_plugin_config +func _get_plugin_config(ptr uint32, size uint32) uint64 + +func (h configService) GetPluginConfig(ctx context.Context, request *GetPluginConfigRequest) (*GetPluginConfigResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_plugin_config(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetPluginConfigResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/config/config_plugin_dev.go b/plugins/host/config/config_plugin_dev.go new file mode 100644 index 000000000..dddbc9ceb --- /dev/null +++ b/plugins/host/config/config_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package config + +func NewConfigService() ConfigService { + panic("not implemented") +} diff --git a/plugins/host/config/config_vtproto.pb.go b/plugins/host/config/config_vtproto.pb.go new file mode 100644 index 000000000..295da164d --- /dev/null +++ b/plugins/host/config/config_vtproto.pb.go @@ -0,0 +1,466 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/config/config.proto + +package config + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *GetPluginConfigRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetPluginConfigRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetPluginConfigRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *GetPluginConfigResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetPluginConfigResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetPluginConfigResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Config) > 0 { + for k := range m.Config { + v := m.Config[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *GetPluginConfigRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *GetPluginConfigResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Config) > 0 { + for k, v := range m.Config { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *GetPluginConfigRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetPluginConfigRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetPluginConfigRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetPluginConfigResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetPluginConfigResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetPluginConfigResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Config == nil { + m.Config = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Config[mapkey] = mapvalue + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/http/http.pb.go b/plugins/host/http/http.pb.go new file mode 100644 index 000000000..0bc2c5040 --- /dev/null +++ b/plugins/host/http/http.pb.go @@ -0,0 +1,117 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/http/http.proto + +package http + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HttpRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + TimeoutMs int32 `protobuf:"varint,3,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"` + Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` // Ignored for GET/DELETE/HEAD/OPTIONS +} + +func (x *HttpRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HttpRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *HttpRequest) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *HttpRequest) GetTimeoutMs() int32 { + if x != nil { + return x.TimeoutMs + } + return 0 +} + +func (x *HttpRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +type HttpResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + Headers map[string]string `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if network/protocol error +} + +func (x *HttpResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HttpResponse) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *HttpResponse) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *HttpResponse) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *HttpResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type HttpService interface { + Get(context.Context, *HttpRequest) (*HttpResponse, error) + Post(context.Context, *HttpRequest) (*HttpResponse, error) + Put(context.Context, *HttpRequest) (*HttpResponse, error) + Delete(context.Context, *HttpRequest) (*HttpResponse, error) + Patch(context.Context, *HttpRequest) (*HttpResponse, error) + Head(context.Context, *HttpRequest) (*HttpResponse, error) + Options(context.Context, *HttpRequest) (*HttpResponse, error) +} diff --git a/plugins/host/http/http.proto b/plugins/host/http/http.proto new file mode 100644 index 000000000..2ed7a4262 --- /dev/null +++ b/plugins/host/http/http.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package http; + +option go_package = "github.com/navidrome/navidrome/plugins/host/http;http"; + +// go:plugin type=host version=1 +service HttpService { + rpc Get(HttpRequest) returns (HttpResponse); + rpc Post(HttpRequest) returns (HttpResponse); + rpc Put(HttpRequest) returns (HttpResponse); + rpc Delete(HttpRequest) returns (HttpResponse); + rpc Patch(HttpRequest) returns (HttpResponse); + rpc Head(HttpRequest) returns (HttpResponse); + rpc Options(HttpRequest) returns (HttpResponse); +} + +message HttpRequest { + string url = 1; + map<string, string> headers = 2; + int32 timeout_ms = 3; + bytes body = 4; // Ignored for GET/DELETE/HEAD/OPTIONS +} + +message HttpResponse { + int32 status = 1; + bytes body = 2; + map<string, string> headers = 3; + string error = 4; // Non-empty if network/protocol error +} \ No newline at end of file diff --git a/plugins/host/http/http_host.pb.go b/plugins/host/http/http_host.pb.go new file mode 100644 index 000000000..326aba508 --- /dev/null +++ b/plugins/host/http/http_host.pb.go @@ -0,0 +1,258 @@ +//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: host/http/http.proto + +package http + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _httpService struct { + HttpService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions HttpService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _httpService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Get), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Post), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("post") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Put), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("put") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Delete), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("delete") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Patch), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("patch") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Head), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("head") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Options), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("options") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _httpService) _Get(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Get(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Post(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Post(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Put(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Put(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Delete(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Delete(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Patch(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Patch(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Head(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Head(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Options(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Options(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/http/http_plugin.pb.go b/plugins/host/http/http_plugin.pb.go new file mode 100644 index 000000000..2e8c21891 --- /dev/null +++ b/plugins/host/http/http_plugin.pb.go @@ -0,0 +1,182 @@ +//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: host/http/http.proto + +package http + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type httpService struct{} + +func NewHttpService() HttpService { + return httpService{} +} + +//go:wasmimport env get +func _get(ptr uint32, size uint32) uint64 + +func (h httpService) Get(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env post +func _post(ptr uint32, size uint32) uint64 + +func (h httpService) Post(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _post(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env put +func _put(ptr uint32, size uint32) uint64 + +func (h httpService) Put(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _put(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env delete +func _delete(ptr uint32, size uint32) uint64 + +func (h httpService) Delete(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _delete(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env patch +func _patch(ptr uint32, size uint32) uint64 + +func (h httpService) Patch(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _patch(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env head +func _head(ptr uint32, size uint32) uint64 + +func (h httpService) Head(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _head(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env options +func _options(ptr uint32, size uint32) uint64 + +func (h httpService) Options(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _options(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/http/http_plugin_dev.go b/plugins/host/http/http_plugin_dev.go new file mode 100644 index 000000000..04e3c2508 --- /dev/null +++ b/plugins/host/http/http_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package http + +func NewHttpService() HttpService { + panic("not implemented") +} diff --git a/plugins/host/http/http_vtproto.pb.go b/plugins/host/http/http_vtproto.pb.go new file mode 100644 index 000000000..064fdb08a --- /dev/null +++ b/plugins/host/http/http_vtproto.pb.go @@ -0,0 +1,850 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/http/http.proto + +package http + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *HttpRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HttpRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HttpRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Body) > 0 { + i -= len(m.Body) + copy(dAtA[i:], m.Body) + i = encodeVarint(dAtA, i, uint64(len(m.Body))) + i-- + dAtA[i] = 0x22 + } + if m.TimeoutMs != 0 { + i = encodeVarint(dAtA, i, uint64(m.TimeoutMs)) + i-- + dAtA[i] = 0x18 + } + if len(m.Headers) > 0 { + for k := range m.Headers { + v := m.Headers[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0x12 + } + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *HttpResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HttpResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HttpResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x22 + } + if len(m.Headers) > 0 { + for k := range m.Headers { + v := m.Headers[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0x1a + } + } + if len(m.Body) > 0 { + i -= len(m.Body) + copy(dAtA[i:], m.Body) + i = encodeVarint(dAtA, i, uint64(len(m.Body))) + i-- + dAtA[i] = 0x12 + } + if m.Status != 0 { + i = encodeVarint(dAtA, i, uint64(m.Status)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *HttpRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Headers) > 0 { + for k, v := range m.Headers { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + if m.TimeoutMs != 0 { + n += 1 + sov(uint64(m.TimeoutMs)) + } + l = len(m.Body) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *HttpResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Status != 0 { + n += 1 + sov(uint64(m.Status)) + } + l = len(m.Body) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Headers) > 0 { + for k, v := range m.Headers { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *HttpRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HttpRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HttpRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Headers == nil { + m.Headers = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Headers[mapkey] = mapvalue + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TimeoutMs", wireType) + } + m.TimeoutMs = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TimeoutMs |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...) + if m.Body == nil { + m.Body = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *HttpResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HttpResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HttpResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Status", wireType) + } + m.Status = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Status |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...) + if m.Body == nil { + m.Body = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Headers == nil { + m.Headers = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Headers[mapkey] = mapvalue + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/scheduler/scheduler.pb.go b/plugins/host/scheduler/scheduler.pb.go new file mode 100644 index 000000000..6d4c29205 --- /dev/null +++ b/plugins/host/scheduler/scheduler.pb.go @@ -0,0 +1,165 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/scheduler/scheduler.proto + +package scheduler + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ScheduleOneTimeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + DelaySeconds int32 `protobuf:"varint,1,opt,name=delay_seconds,json=delaySeconds,proto3" json:"delay_seconds,omitempty"` // Delay in seconds + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback + ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated) +} + +func (x *ScheduleOneTimeRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScheduleOneTimeRequest) GetDelaySeconds() int32 { + if x != nil { + return x.DelaySeconds + } + return 0 +} + +func (x *ScheduleOneTimeRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ScheduleOneTimeRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type ScheduleRecurringRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CronExpression string `protobuf:"bytes,1,opt,name=cron_expression,json=cronExpression,proto3" json:"cron_expression,omitempty"` // Cron expression (e.g. "0 0 * * *" for daily at midnight) + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback + ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated) +} + +func (x *ScheduleRecurringRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScheduleRecurringRequest) GetCronExpression() string { + if x != nil { + return x.CronExpression + } + return "" +} + +func (x *ScheduleRecurringRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ScheduleRecurringRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type ScheduleResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID to reference this scheduled job +} + +func (x *ScheduleResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScheduleResponse) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type CancelRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the schedule to cancel +} + +func (x *CancelRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CancelRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type CancelResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether cancellation was successful + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Error message if cancellation failed +} + +func (x *CancelResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CancelResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CancelResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type SchedulerService interface { + // One-time event scheduling + ScheduleOneTime(context.Context, *ScheduleOneTimeRequest) (*ScheduleResponse, error) + // Recurring event scheduling + ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error) + // Cancel any scheduled job + CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error) +} diff --git a/plugins/host/scheduler/scheduler.proto b/plugins/host/scheduler/scheduler.proto new file mode 100644 index 000000000..39fd32a58 --- /dev/null +++ b/plugins/host/scheduler/scheduler.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; + +package scheduler; + +option go_package = "github.com/navidrome/navidrome/plugins/host/scheduler;scheduler"; + +// go:plugin type=host version=1 +service SchedulerService { + // One-time event scheduling + rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse); + + // Recurring event scheduling + rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse); + + // Cancel any scheduled job + rpc CancelSchedule(CancelRequest) returns (CancelResponse); +} + +message ScheduleOneTimeRequest { + int32 delay_seconds = 1; // Delay in seconds + bytes payload = 2; // Serialized data to pass to the callback + string schedule_id = 3; // Optional custom ID (if not provided, one will be generated) +} + +message ScheduleRecurringRequest { + string cron_expression = 1; // Cron expression (e.g. "0 0 * * *" for daily at midnight) + bytes payload = 2; // Serialized data to pass to the callback + string schedule_id = 3; // Optional custom ID (if not provided, one will be generated) +} + +message ScheduleResponse { + string schedule_id = 1; // ID to reference this scheduled job +} + +message CancelRequest { + string schedule_id = 1; // ID of the schedule to cancel +} + +message CancelResponse { + bool success = 1; // Whether cancellation was successful + string error = 2; // Error message if cancellation failed +} \ No newline at end of file diff --git a/plugins/host/scheduler/scheduler_host.pb.go b/plugins/host/scheduler/scheduler_host.pb.go new file mode 100644 index 000000000..289f3f0bb --- /dev/null +++ b/plugins/host/scheduler/scheduler_host.pb.go @@ -0,0 +1,136 @@ +//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: host/scheduler/scheduler.proto + +package scheduler + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _schedulerService struct { + SchedulerService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _schedulerService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._ScheduleOneTime), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("schedule_one_time") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._ScheduleRecurring), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("schedule_recurring") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._CancelSchedule), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("cancel_schedule") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +// One-time event scheduling + +func (h _schedulerService) _ScheduleOneTime(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(ScheduleOneTimeRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.ScheduleOneTime(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Recurring event scheduling + +func (h _schedulerService) _ScheduleRecurring(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(ScheduleRecurringRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.ScheduleRecurring(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Cancel any scheduled job + +func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(CancelRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.CancelSchedule(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/scheduler/scheduler_plugin.pb.go b/plugins/host/scheduler/scheduler_plugin.pb.go new file mode 100644 index 000000000..afbed2bf0 --- /dev/null +++ b/plugins/host/scheduler/scheduler_plugin.pb.go @@ -0,0 +1,90 @@ +//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: host/scheduler/scheduler.proto + +package scheduler + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type schedulerService struct{} + +func NewSchedulerService() SchedulerService { + return schedulerService{} +} + +//go:wasmimport env schedule_one_time +func _schedule_one_time(ptr uint32, size uint32) uint64 + +func (h schedulerService) ScheduleOneTime(ctx context.Context, request *ScheduleOneTimeRequest) (*ScheduleResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _schedule_one_time(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(ScheduleResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env schedule_recurring +func _schedule_recurring(ptr uint32, size uint32) uint64 + +func (h schedulerService) ScheduleRecurring(ctx context.Context, request *ScheduleRecurringRequest) (*ScheduleResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _schedule_recurring(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(ScheduleResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env cancel_schedule +func _cancel_schedule(ptr uint32, size uint32) uint64 + +func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelRequest) (*CancelResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _cancel_schedule(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(CancelResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/scheduler/scheduler_plugin_dev.go b/plugins/host/scheduler/scheduler_plugin_dev.go new file mode 100644 index 000000000..b6feaa8e4 --- /dev/null +++ b/plugins/host/scheduler/scheduler_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package scheduler + +func NewSchedulerService() SchedulerService { + panic("not implemented") +} diff --git a/plugins/host/scheduler/scheduler_vtproto.pb.go b/plugins/host/scheduler/scheduler_vtproto.pb.go new file mode 100644 index 000000000..1606ab7f0 --- /dev/null +++ b/plugins/host/scheduler/scheduler_vtproto.pb.go @@ -0,0 +1,1002 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/scheduler/scheduler.proto + +package scheduler + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *ScheduleOneTimeRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScheduleOneTimeRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScheduleOneTimeRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0x1a + } + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarint(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x12 + } + if m.DelaySeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.DelaySeconds)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *ScheduleRecurringRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScheduleRecurringRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScheduleRecurringRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0x1a + } + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarint(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x12 + } + if len(m.CronExpression) > 0 { + i -= len(m.CronExpression) + copy(dAtA[i:], m.CronExpression) + i = encodeVarint(dAtA, i, uint64(len(m.CronExpression))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScheduleResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScheduleResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScheduleResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CancelRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CancelRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CancelRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CancelResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CancelResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if m.Success { + i-- + if m.Success { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ScheduleOneTimeRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.DelaySeconds != 0 { + n += 1 + sov(uint64(m.DelaySeconds)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScheduleRecurringRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.CronExpression) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScheduleResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CancelRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CancelResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Success { + n += 2 + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ScheduleOneTimeRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScheduleOneTimeRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScheduleOneTimeRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field DelaySeconds", wireType) + } + m.DelaySeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.DelaySeconds |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScheduleRecurringRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScheduleRecurringRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScheduleRecurringRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field CronExpression", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.CronExpression = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScheduleResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScheduleResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScheduleResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CancelRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CancelRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CancelRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CancelResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CancelResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CancelResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Success", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Success = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/subsonicapi/subsonicapi.pb.go b/plugins/host/subsonicapi/subsonicapi.pb.go new file mode 100644 index 000000000..0dbd9054f --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi.pb.go @@ -0,0 +1,71 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CallRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *CallRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CallRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type CallResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Json string `protobuf:"bytes,1,opt,name=json,proto3" json:"json,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if operation failed +} + +func (x *CallResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CallResponse) GetJson() string { + if x != nil { + return x.Json + } + return "" +} + +func (x *CallResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type SubsonicAPIService interface { + Call(context.Context, *CallRequest) (*CallResponse, error) +} diff --git a/plugins/host/subsonicapi/subsonicapi.proto b/plugins/host/subsonicapi/subsonicapi.proto new file mode 100644 index 000000000..29dc365ca --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package subsonicapi; + +option go_package = "github.com/navidrome/navidrome/plugins/host/subsonicapi;subsonicapi"; + +// go:plugin type=host version=1 +service SubsonicAPIService { + rpc Call(CallRequest) returns (CallResponse); +} + +message CallRequest { + string url = 1; +} + +message CallResponse { + string json = 1; + string error = 2; // Non-empty if operation failed +} \ No newline at end of file diff --git a/plugins/host/subsonicapi/subsonicapi_host.pb.go b/plugins/host/subsonicapi/subsonicapi_host.pb.go new file mode 100644 index 000000000..b7c0f042e --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi_host.pb.go @@ -0,0 +1,66 @@ +//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: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _subsonicAPIService struct { + SubsonicAPIService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SubsonicAPIService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _subsonicAPIService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Call), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("call") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _subsonicAPIService) _Call(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(CallRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Call(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/subsonicapi/subsonicapi_plugin.pb.go b/plugins/host/subsonicapi/subsonicapi_plugin.pb.go new file mode 100644 index 000000000..1ffdbf526 --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi_plugin.pb.go @@ -0,0 +1,44 @@ +//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: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type subsonicAPIService struct{} + +func NewSubsonicAPIService() SubsonicAPIService { + return subsonicAPIService{} +} + +//go:wasmimport env call +func _call(ptr uint32, size uint32) uint64 + +func (h subsonicAPIService) Call(ctx context.Context, request *CallRequest) (*CallResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _call(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(CallResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/subsonicapi/subsonicapi_vtproto.pb.go b/plugins/host/subsonicapi/subsonicapi_vtproto.pb.go new file mode 100644 index 000000000..05403216b --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi_vtproto.pb.go @@ -0,0 +1,441 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *CallRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CallRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CallRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CallResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CallResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CallResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if len(m.Json) > 0 { + i -= len(m.Json) + copy(dAtA[i:], m.Json) + i = encodeVarint(dAtA, i, uint64(len(m.Json))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *CallRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CallResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Json) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *CallRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CallRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CallRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CallResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CallResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CallResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Json", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Json = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/websocket/websocket.pb.go b/plugins/host/websocket/websocket.pb.go new file mode 100644 index 000000000..f3ab68963 --- /dev/null +++ b/plugins/host/websocket/websocket.pb.go @@ -0,0 +1,240 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/websocket/websocket.proto + +package websocket + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ConnectRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + ConnectionId string `protobuf:"bytes,3,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` +} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ConnectRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ConnectRequest) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ConnectRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +type ConnectResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ConnectResponse) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *ConnectResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SendTextRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *SendTextRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendTextRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *SendTextRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type SendTextResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SendTextResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendTextResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SendBinaryRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *SendBinaryRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendBinaryRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *SendBinaryRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type SendBinaryResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SendBinaryResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendBinaryResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type CloseRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *CloseRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CloseRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *CloseRequest) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *CloseRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type CloseResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *CloseResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CloseResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type WebSocketService interface { + // Connect to a WebSocket endpoint + Connect(context.Context, *ConnectRequest) (*ConnectResponse, error) + // Send a text message + SendText(context.Context, *SendTextRequest) (*SendTextResponse, error) + // Send binary data + SendBinary(context.Context, *SendBinaryRequest) (*SendBinaryResponse, error) + // Close a connection + Close(context.Context, *CloseRequest) (*CloseResponse, error) +} diff --git a/plugins/host/websocket/websocket.proto b/plugins/host/websocket/websocket.proto new file mode 100644 index 000000000..53adaca95 --- /dev/null +++ b/plugins/host/websocket/websocket.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; +package websocket; +option go_package = "github.com/navidrome/navidrome/plugins/host/websocket"; + +// go:plugin type=host version=1 +service WebSocketService { + // Connect to a WebSocket endpoint + rpc Connect(ConnectRequest) returns (ConnectResponse); + + // Send a text message + rpc SendText(SendTextRequest) returns (SendTextResponse); + + // Send binary data + rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse); + + // Close a connection + rpc Close(CloseRequest) returns (CloseResponse); +} + +message ConnectRequest { + string url = 1; + map<string, string> headers = 2; + string connection_id = 3; +} + +message ConnectResponse { + string connection_id = 1; + string error = 2; +} + +message SendTextRequest { + string connection_id = 1; + string message = 2; +} + +message SendTextResponse { + string error = 1; +} + +message SendBinaryRequest { + string connection_id = 1; + bytes data = 2; +} + +message SendBinaryResponse { + string error = 1; +} + +message CloseRequest { + string connection_id = 1; + int32 code = 2; + string reason = 3; +} + +message CloseResponse { + string error = 1; +} \ No newline at end of file diff --git a/plugins/host/websocket/websocket_host.pb.go b/plugins/host/websocket/websocket_host.pb.go new file mode 100644 index 000000000..b95eb451c --- /dev/null +++ b/plugins/host/websocket/websocket_host.pb.go @@ -0,0 +1,170 @@ +//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: host/websocket/websocket.proto + +package websocket + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _webSocketService struct { + WebSocketService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions WebSocketService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _webSocketService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Connect), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("connect") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SendText), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("send_text") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SendBinary), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("send_binary") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Close), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("close") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +// Connect to a WebSocket endpoint + +func (h _webSocketService) _Connect(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(ConnectRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Connect(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Send a text message + +func (h _webSocketService) _SendText(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SendTextRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SendText(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Send binary data + +func (h _webSocketService) _SendBinary(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SendBinaryRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SendBinary(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Close a connection + +func (h _webSocketService) _Close(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(CloseRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Close(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/websocket/websocket_plugin.pb.go b/plugins/host/websocket/websocket_plugin.pb.go new file mode 100644 index 000000000..e7d5c3fe0 --- /dev/null +++ b/plugins/host/websocket/websocket_plugin.pb.go @@ -0,0 +1,113 @@ +//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: host/websocket/websocket.proto + +package websocket + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type webSocketService struct{} + +func NewWebSocketService() WebSocketService { + return webSocketService{} +} + +//go:wasmimport env connect +func _connect(ptr uint32, size uint32) uint64 + +func (h webSocketService) Connect(ctx context.Context, request *ConnectRequest) (*ConnectResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _connect(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(ConnectResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env send_text +func _send_text(ptr uint32, size uint32) uint64 + +func (h webSocketService) SendText(ctx context.Context, request *SendTextRequest) (*SendTextResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _send_text(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SendTextResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env send_binary +func _send_binary(ptr uint32, size uint32) uint64 + +func (h webSocketService) SendBinary(ctx context.Context, request *SendBinaryRequest) (*SendBinaryResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _send_binary(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SendBinaryResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env close +func _close(ptr uint32, size uint32) uint64 + +func (h webSocketService) Close(ctx context.Context, request *CloseRequest) (*CloseResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _close(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(CloseResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/websocket/websocket_plugin_dev.go b/plugins/host/websocket/websocket_plugin_dev.go new file mode 100644 index 000000000..cfb72462a --- /dev/null +++ b/plugins/host/websocket/websocket_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package websocket + +func NewWebSocketService() WebSocketService { + panic("not implemented") +} diff --git a/plugins/host/websocket/websocket_vtproto.pb.go b/plugins/host/websocket/websocket_vtproto.pb.go new file mode 100644 index 000000000..fb15a22b7 --- /dev/null +++ b/plugins/host/websocket/websocket_vtproto.pb.go @@ -0,0 +1,1618 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/websocket/websocket.proto + +package websocket + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *ConnectRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ConnectRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ConnectRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0x1a + } + if len(m.Headers) > 0 { + for k := range m.Headers { + v := m.Headers[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0x12 + } + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ConnectResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ConnectResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ConnectResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendTextRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendTextRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendTextRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Message) > 0 { + i -= len(m.Message) + copy(dAtA[i:], m.Message) + i = encodeVarint(dAtA, i, uint64(len(m.Message))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendTextResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendTextResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendTextResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendBinaryRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendBinaryRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendBinaryRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Data) > 0 { + i -= len(m.Data) + copy(dAtA[i:], m.Data) + i = encodeVarint(dAtA, i, uint64(len(m.Data))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendBinaryResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendBinaryResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendBinaryResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CloseRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CloseRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CloseRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Reason) > 0 { + i -= len(m.Reason) + copy(dAtA[i:], m.Reason) + i = encodeVarint(dAtA, i, uint64(len(m.Reason))) + i-- + dAtA[i] = 0x1a + } + if m.Code != 0 { + i = encodeVarint(dAtA, i, uint64(m.Code)) + i-- + dAtA[i] = 0x10 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CloseResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CloseResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CloseResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ConnectRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Headers) > 0 { + for k, v := range m.Headers { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ConnectResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendTextRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Message) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendTextResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendBinaryRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Data) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendBinaryResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CloseRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Code != 0 { + n += 1 + sov(uint64(m.Code)) + } + l = len(m.Reason) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CloseResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ConnectRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ConnectRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ConnectRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Headers == nil { + m.Headers = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Headers[mapkey] = mapvalue + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ConnectResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ConnectResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ConnectResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendTextRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendTextRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendTextRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Message", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Message = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendTextResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendTextResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendTextResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendBinaryRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendBinaryRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendBinaryRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Data", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Data = append(m.Data[:0], dAtA[iNdEx:postIndex]...) + if m.Data == nil { + m.Data = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendBinaryResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendBinaryResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendBinaryResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CloseRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CloseRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CloseRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Code", wireType) + } + m.Code = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Code |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Reason", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Reason = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CloseResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CloseResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CloseResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go new file mode 100644 index 000000000..dac622206 --- /dev/null +++ b/plugins/host_artwork.go @@ -0,0 +1,47 @@ +package plugins + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/host/artwork" + "github.com/navidrome/navidrome/server/public" +) + +type artworkServiceImpl struct{} + +func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) { + artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: req.Id} + imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size)) + return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil +} + +func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) { + artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: req.Id} + imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size)) + return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil +} + +func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) { + artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: req.Id} + imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size)) + return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil +} + +func (a *artworkServiceImpl) createRequest() *http.Request { + var scheme, host string + if conf.Server.ShareURL != "" { + shareURL, _ := url.Parse(conf.Server.ShareURL) + scheme = shareURL.Scheme + host = shareURL.Host + } else { + scheme = "http" + host = "localhost" + } + r, _ := http.NewRequest("GET", fmt.Sprintf("%s://%s", scheme, host), nil) + return r +} diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go new file mode 100644 index 000000000..b6667bde3 --- /dev/null +++ b/plugins/host_artwork_test.go @@ -0,0 +1,58 @@ +package plugins + +import ( + "context" + + "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/plugins/host/artwork" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ArtworkService", func() { + var svc *artworkServiceImpl + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // Setup auth for tests + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + svc = &artworkServiceImpl{} + }) + + Context("with ShareURL configured", func() { + BeforeEach(func() { + conf.Server.ShareURL = "https://music.example.com" + }) + + It("returns artist artwork URL", func() { + resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123", Size: 300}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("https://music.example.com")) + Expect(resp.Url).To(ContainSubstring("size=300")) + }) + + It("returns album artwork URL", func() { + resp, err := svc.GetAlbumUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "456"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("https://music.example.com")) + }) + + It("returns track artwork URL", func() { + resp, err := svc.GetTrackUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "789", Size: 150}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("https://music.example.com")) + Expect(resp.Url).To(ContainSubstring("size=150")) + }) + }) + + Context("without ShareURL configured", func() { + It("returns localhost URLs", func() { + resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("http://localhost")) + }) + }) +}) diff --git a/plugins/host_cache.go b/plugins/host_cache.go new file mode 100644 index 000000000..291a17870 --- /dev/null +++ b/plugins/host_cache.go @@ -0,0 +1,152 @@ +package plugins + +import ( + "context" + "sync" + "time" + + "github.com/jellydator/ttlcache/v3" + "github.com/navidrome/navidrome/log" + cacheproto "github.com/navidrome/navidrome/plugins/host/cache" +) + +const ( + defaultCacheTTL = 24 * time.Hour +) + +// cacheServiceImpl implements the cache.CacheService interface +type cacheServiceImpl struct { + pluginID string + defaultTTL time.Duration +} + +var ( + _cache *ttlcache.Cache[string, any] + initCacheOnce sync.Once +) + +// newCacheService creates a new cacheServiceImpl instance +func newCacheService(pluginID string) *cacheServiceImpl { + initCacheOnce.Do(func() { + opts := []ttlcache.Option[string, any]{ + ttlcache.WithTTL[string, any](defaultCacheTTL), + } + _cache = ttlcache.New[string, any](opts...) + + // Start the janitor goroutine to clean up expired entries + go _cache.Start() + }) + + return &cacheServiceImpl{ + pluginID: pluginID, + defaultTTL: defaultCacheTTL, + } +} + +// mapKey combines the plugin name and a provided key to create a unique cache key. +func (s *cacheServiceImpl) mapKey(key string) string { + return s.pluginID + ":" + key +} + +// getTTL converts seconds to a duration, using default if 0 +func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration { + if seconds <= 0 { + return s.defaultTTL + } + return time.Duration(seconds) * time.Second +} + +// setCacheValue is a generic function to set a value in the cache +func setCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, value T, ttlSeconds int64) (*cacheproto.SetResponse, error) { + ttl := cs.getTTL(ttlSeconds) + key = cs.mapKey(key) + _cache.Set(key, value, ttl) + return &cacheproto.SetResponse{Success: true}, nil +} + +// getCacheValue is a generic function to get a value from the cache +func getCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, typeName string) (T, bool, error) { + key = cs.mapKey(key) + var zero T + item := _cache.Get(key) + if item == nil { + return zero, false, nil + } + + value, ok := item.Value().(T) + if !ok { + log.Debug(ctx, "Type mismatch in cache", "plugin", cs.pluginID, "key", key, "expected", typeName) + return zero, false, nil + } + return value, true, nil +} + +// SetString sets a string value in the cache +func (s *cacheServiceImpl) SetString(ctx context.Context, req *cacheproto.SetStringRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetString gets a string value from the cache +func (s *cacheServiceImpl) GetString(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetStringResponse, error) { + value, exists, err := getCacheValue[string](ctx, s, req.Key, "string") + if err != nil { + return nil, err + } + return &cacheproto.GetStringResponse{Exists: exists, Value: value}, nil +} + +// SetInt sets an integer value in the cache +func (s *cacheServiceImpl) SetInt(ctx context.Context, req *cacheproto.SetIntRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetInt gets an integer value from the cache +func (s *cacheServiceImpl) GetInt(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetIntResponse, error) { + value, exists, err := getCacheValue[int64](ctx, s, req.Key, "int64") + if err != nil { + return nil, err + } + return &cacheproto.GetIntResponse{Exists: exists, Value: value}, nil +} + +// SetFloat sets a float value in the cache +func (s *cacheServiceImpl) SetFloat(ctx context.Context, req *cacheproto.SetFloatRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetFloat gets a float value from the cache +func (s *cacheServiceImpl) GetFloat(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetFloatResponse, error) { + value, exists, err := getCacheValue[float64](ctx, s, req.Key, "float64") + if err != nil { + return nil, err + } + return &cacheproto.GetFloatResponse{Exists: exists, Value: value}, nil +} + +// SetBytes sets a byte slice value in the cache +func (s *cacheServiceImpl) SetBytes(ctx context.Context, req *cacheproto.SetBytesRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetBytes gets a byte slice value from the cache +func (s *cacheServiceImpl) GetBytes(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetBytesResponse, error) { + value, exists, err := getCacheValue[[]byte](ctx, s, req.Key, "[]byte") + if err != nil { + return nil, err + } + return &cacheproto.GetBytesResponse{Exists: exists, Value: value}, nil +} + +// Remove removes a value from the cache +func (s *cacheServiceImpl) Remove(ctx context.Context, req *cacheproto.RemoveRequest) (*cacheproto.RemoveResponse, error) { + key := s.mapKey(req.Key) + _cache.Delete(key) + return &cacheproto.RemoveResponse{Success: true}, nil +} + +// Has checks if a key exists in the cache +func (s *cacheServiceImpl) Has(ctx context.Context, req *cacheproto.HasRequest) (*cacheproto.HasResponse, error) { + key := s.mapKey(req.Key) + item := _cache.Get(key) + return &cacheproto.HasResponse{Exists: item != nil}, nil +} diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go new file mode 100644 index 000000000..efb03e289 --- /dev/null +++ b/plugins/host_cache_test.go @@ -0,0 +1,171 @@ +package plugins + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/plugins/host/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CacheService", func() { + var service *cacheServiceImpl + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + service = newCacheService("test_plugin") + }) + + Describe("getTTL", func() { + It("returns default TTL when seconds is 0", func() { + ttl := service.getTTL(0) + Expect(ttl).To(Equal(defaultCacheTTL)) + }) + + It("returns default TTL when seconds is negative", func() { + ttl := service.getTTL(-10) + Expect(ttl).To(Equal(defaultCacheTTL)) + }) + + It("returns correct duration when seconds is positive", func() { + ttl := service.getTTL(60) + Expect(ttl).To(Equal(time.Minute)) + }) + }) + + Describe("String Operations", func() { + It("sets and gets a string value", func() { + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "string_key", + Value: "test_value", + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetString(ctx, &cache.GetRequest{Key: "string_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal("test_value")) + }) + + It("returns not exists for missing key", func() { + res, err := service.GetString(ctx, &cache.GetRequest{Key: "missing_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) + + Describe("Integer Operations", func() { + It("sets and gets an integer value", func() { + _, err := service.SetInt(ctx, &cache.SetIntRequest{ + Key: "int_key", + Value: 42, + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetInt(ctx, &cache.GetRequest{Key: "int_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal(int64(42))) + }) + }) + + Describe("Float Operations", func() { + It("sets and gets a float value", func() { + _, err := service.SetFloat(ctx, &cache.SetFloatRequest{ + Key: "float_key", + Value: 3.14, + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetFloat(ctx, &cache.GetRequest{Key: "float_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal(3.14)) + }) + }) + + Describe("Bytes Operations", func() { + It("sets and gets a bytes value", func() { + byteData := []byte("hello world") + _, err := service.SetBytes(ctx, &cache.SetBytesRequest{ + Key: "bytes_key", + Value: byteData, + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetBytes(ctx, &cache.GetRequest{Key: "bytes_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal(byteData)) + }) + }) + + Describe("Type mismatch handling", func() { + It("returns not exists when type doesn't match the getter", func() { + // Set string + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "mixed_key", + Value: "string value", + }) + Expect(err).NotTo(HaveOccurred()) + + // Try to get as int + res, err := service.GetInt(ctx, &cache.GetRequest{Key: "mixed_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) + + Describe("Remove Operation", func() { + It("removes a value from the cache", func() { + // Set a value + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "remove_key", + Value: "to be removed", + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify it exists + res, err := service.Has(ctx, &cache.HasRequest{Key: "remove_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + + // Remove it + _, err = service.Remove(ctx, &cache.RemoveRequest{Key: "remove_key"}) + Expect(err).NotTo(HaveOccurred()) + + // Verify it's gone + res, err = service.Has(ctx, &cache.HasRequest{Key: "remove_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) + + Describe("Has Operation", func() { + It("returns true for existing key", func() { + // Set a value + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "existing_key", + Value: "exists", + }) + Expect(err).NotTo(HaveOccurred()) + + // Check if it exists + res, err := service.Has(ctx, &cache.HasRequest{Key: "existing_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + }) + + It("returns false for non-existing key", func() { + res, err := service.Has(ctx, &cache.HasRequest{Key: "non_existing_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) +}) diff --git a/plugins/host_config.go b/plugins/host_config.go new file mode 100644 index 000000000..baee6a00c --- /dev/null +++ b/plugins/host_config.go @@ -0,0 +1,22 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/plugins/host/config" +) + +type configServiceImpl struct { + pluginID string +} + +func (c *configServiceImpl) GetPluginConfig(ctx context.Context, req *config.GetPluginConfigRequest) (*config.GetPluginConfigResponse, error) { + cfg, ok := conf.Server.PluginConfig[c.pluginID] + if !ok { + cfg = map[string]string{} + } + return &config.GetPluginConfigResponse{ + Config: cfg, + }, nil +} diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go new file mode 100644 index 000000000..bae7043be --- /dev/null +++ b/plugins/host_config_test.go @@ -0,0 +1,46 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + hostconfig "github.com/navidrome/navidrome/plugins/host/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("configServiceImpl", func() { + var ( + svc *configServiceImpl + pluginName string + ) + + BeforeEach(func() { + pluginName = "testplugin" + svc = &configServiceImpl{pluginID: pluginName} + conf.Server.PluginConfig = map[string]map[string]string{ + pluginName: {"foo": "bar", "baz": "qux"}, + } + }) + + It("returns config for known plugin", func() { + resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{}) + Expect(err).To(BeNil()) + Expect(resp.Config).To(HaveKeyWithValue("foo", "bar")) + Expect(resp.Config).To(HaveKeyWithValue("baz", "qux")) + }) + + It("returns error for unknown plugin", func() { + svc.pluginID = "unknown" + resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{}) + Expect(err).To(BeNil()) + Expect(resp.Config).To(BeEmpty()) + }) + + It("returns empty config if plugin config is empty", func() { + conf.Server.PluginConfig[pluginName] = map[string]string{} + resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{}) + Expect(err).To(BeNil()) + Expect(resp.Config).To(BeEmpty()) + }) +}) diff --git a/plugins/host_http.go b/plugins/host_http.go new file mode 100644 index 000000000..24fc77b18 --- /dev/null +++ b/plugins/host_http.go @@ -0,0 +1,114 @@ +package plugins + +import ( + "bytes" + "cmp" + "context" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + hosthttp "github.com/navidrome/navidrome/plugins/host/http" +) + +type httpServiceImpl struct { + pluginID string + permissions *httpPermissions +} + +const defaultTimeout = 10 * time.Second + +func (s *httpServiceImpl) Get(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodGet, req) +} + +func (s *httpServiceImpl) Post(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodPost, req) +} + +func (s *httpServiceImpl) Put(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodPut, req) +} + +func (s *httpServiceImpl) Delete(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodDelete, req) +} + +func (s *httpServiceImpl) Patch(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodPatch, req) +} + +func (s *httpServiceImpl) Head(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodHead, req) +} + +func (s *httpServiceImpl) Options(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodOptions, req) +} + +func (s *httpServiceImpl) doHttp(ctx context.Context, method string, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + // Check permissions if they exist + if s.permissions != nil { + if err := s.permissions.IsRequestAllowed(req.Url, method); err != nil { + log.Warn(ctx, "HTTP request blocked by permissions", "plugin", s.pluginID, "url", req.Url, "method", method, err) + return &hosthttp.HttpResponse{Error: "Request blocked by plugin permissions: " + err.Error()}, nil + } + } + client := &http.Client{ + Timeout: cmp.Or(time.Duration(req.TimeoutMs)*time.Millisecond, defaultTimeout), + } + + // Configure redirect policy based on permissions + if s.permissions != nil { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // Enforce maximum redirect limit + if len(via) >= httpMaxRedirects { + log.Warn(ctx, "HTTP redirect limit exceeded", "plugin", s.pluginID, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + + // Check if redirect destination is allowed + if err := s.permissions.IsRequestAllowed(req.URL.String(), req.Method); err != nil { + log.Warn(ctx, "HTTP redirect blocked by permissions", "plugin", s.pluginID, "url", req.URL.String(), "method", req.Method, err) + return http.ErrUseLastResponse + } + + return nil // Allow redirect + } + } + var body io.Reader + if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch { + body = bytes.NewReader(req.Body) + } + httpReq, err := http.NewRequestWithContext(ctx, method, req.Url, body) + if err != nil { + return nil, err + } + for k, v := range req.Headers { + httpReq.Header.Set(k, v) + } + resp, err := client.Do(httpReq) + if err != nil { + log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, err) + return &hosthttp.HttpResponse{Error: err.Error()}, nil + } + log.Trace(ctx, "HttpService request", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode) + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode, err) + return &hosthttp.HttpResponse{Error: err.Error()}, nil + } + headers := map[string]string{} + for k, v := range resp.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + return &hosthttp.HttpResponse{ + Status: int32(resp.StatusCode), + Body: respBody, + Headers: headers, + }, nil +} diff --git a/plugins/host_http_permissions.go b/plugins/host_http_permissions.go new file mode 100644 index 000000000..158bdb105 --- /dev/null +++ b/plugins/host_http_permissions.go @@ -0,0 +1,90 @@ +package plugins + +import ( + "fmt" + "strings" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// Maximum number of HTTP redirects allowed for plugin requests +const httpMaxRedirects = 5 + +// HTTPPermissions represents granular HTTP access permissions for plugins +type httpPermissions struct { + *networkPermissionsBase + AllowedUrls map[string][]string `json:"allowedUrls"` + matcher *urlMatcher +} + +// parseHTTPPermissions extracts HTTP permissions from the schema +func parseHTTPPermissions(permData *schema.PluginManifestPermissionsHttp) (*httpPermissions, error) { + base := &networkPermissionsBase{ + AllowLocalNetwork: permData.AllowLocalNetwork, + } + + if len(permData.AllowedUrls) == 0 { + return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern") + } + + allowedUrls := make(map[string][]string) + for urlPattern, methodEnums := range permData.AllowedUrls { + methods := make([]string, len(methodEnums)) + for i, methodEnum := range methodEnums { + methods[i] = string(methodEnum) + } + allowedUrls[urlPattern] = methods + } + + return &httpPermissions{ + networkPermissionsBase: base, + AllowedUrls: allowedUrls, + matcher: newURLMatcher(), + }, nil +} + +// IsRequestAllowed checks if a specific network request is allowed by the permissions +func (p *httpPermissions) IsRequestAllowed(requestURL, operation string) error { + if _, err := checkURLPolicy(requestURL, p.AllowLocalNetwork); err != nil { + return err + } + + // allowedUrls is now required - no fallback to allow all URLs + if p.AllowedUrls == nil || len(p.AllowedUrls) == 0 { + return fmt.Errorf("no allowed URLs configured for plugin") + } + + matcher := newURLMatcher() + + // Check URL patterns and operations + // First try exact matches, then wildcard matches + operation = strings.ToUpper(operation) + + // Phase 1: Check for exact matches first + for urlPattern, allowedOperations := range p.AllowedUrls { + if !strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) { + // Check if operation is allowed + for _, allowedOperation := range allowedOperations { + if allowedOperation == "*" || allowedOperation == operation { + return nil + } + } + return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern) + } + } + + // Phase 2: Check wildcard patterns + for urlPattern, allowedOperations := range p.AllowedUrls { + if strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) { + // Check if operation is allowed + for _, allowedOperation := range allowedOperations { + if allowedOperation == "*" || allowedOperation == operation { + return nil + } + } + return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern) + } + } + + return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL) +} diff --git a/plugins/host_http_permissions_test.go b/plugins/host_http_permissions_test.go new file mode 100644 index 000000000..3385ffc03 --- /dev/null +++ b/plugins/host_http_permissions_test.go @@ -0,0 +1,187 @@ +package plugins + +import ( + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("HTTP Permissions", func() { + Describe("parseHTTPPermissions", func() { + It("should parse valid HTTP permissions", func() { + permData := &schema.PluginManifestPermissionsHttp{ + Reason: "Need to fetch album artwork", + AllowLocalNetwork: false, + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "https://api.example.com/*": { + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET, + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemPOST, + }, + "https://cdn.example.com/*": { + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET, + }, + }, + } + + perms, err := parseHTTPPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms).ToNot(BeNil()) + Expect(perms.AllowLocalNetwork).To(BeFalse()) + Expect(perms.AllowedUrls).To(HaveLen(2)) + Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"GET", "POST"})) + Expect(perms.AllowedUrls["https://cdn.example.com/*"]).To(Equal([]string{"GET"})) + }) + + It("should fail if allowedUrls is empty", func() { + permData := &schema.PluginManifestPermissionsHttp{ + Reason: "Need to fetch album artwork", + AllowLocalNetwork: false, + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{}, + } + + _, err := parseHTTPPermissions(permData) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern")) + }) + + It("should handle method enum types correctly", func() { + permData := &schema.PluginManifestPermissionsHttp{ + Reason: "Need to fetch album artwork", + AllowLocalNetwork: false, + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "https://api.example.com/*": { + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard, // "*" + }, + }, + } + + perms, err := parseHTTPPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"*"})) + }) + }) + + Describe("IsRequestAllowed", func() { + var perms *httpPermissions + + Context("HTTP method-specific validation", func() { + BeforeEach(func() { + perms = &httpPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + Reason: "Test permissions", + AllowLocalNetwork: false, + }, + AllowedUrls: map[string][]string{ + "https://api.example.com": {"GET", "POST"}, + "https://upload.example.com": {"PUT", "PATCH"}, + "https://admin.example.com": {"DELETE"}, + "https://webhook.example.com": {"*"}, + }, + matcher: newURLMatcher(), + } + }) + + DescribeTable("method-specific access control", + func(url, method string, shouldSucceed bool) { + err := perms.IsRequestAllowed(url, method) + if shouldSucceed { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + // Allowed methods + Entry("GET to api", "https://api.example.com", "GET", true), + Entry("POST to api", "https://api.example.com", "POST", true), + Entry("PUT to upload", "https://upload.example.com", "PUT", true), + Entry("PATCH to upload", "https://upload.example.com", "PATCH", true), + Entry("DELETE to admin", "https://admin.example.com", "DELETE", true), + Entry("any method to webhook", "https://webhook.example.com", "OPTIONS", true), + Entry("any method to webhook", "https://webhook.example.com", "HEAD", true), + + // Disallowed methods + Entry("DELETE to api", "https://api.example.com", "DELETE", false), + Entry("GET to upload", "https://upload.example.com", "GET", false), + Entry("POST to admin", "https://admin.example.com", "POST", false), + ) + }) + + Context("case insensitive method handling", func() { + BeforeEach(func() { + perms = &httpPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + Reason: "Test permissions", + AllowLocalNetwork: false, + }, + AllowedUrls: map[string][]string{ + "https://api.example.com": {"GET", "POST"}, // Both uppercase for consistency + }, + matcher: newURLMatcher(), + } + }) + + DescribeTable("case insensitive method matching", + func(method string, shouldSucceed bool) { + err := perms.IsRequestAllowed("https://api.example.com", method) + if shouldSucceed { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + Entry("uppercase GET", "GET", true), + Entry("lowercase get", "get", true), + Entry("mixed case Get", "Get", true), + Entry("uppercase POST", "POST", true), + Entry("lowercase post", "post", true), + Entry("mixed case Post", "Post", true), + Entry("disallowed method", "DELETE", false), + ) + }) + + Context("with complex URL patterns and HTTP methods", func() { + BeforeEach(func() { + perms = &httpPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + Reason: "Test permissions", + AllowLocalNetwork: false, + }, + AllowedUrls: map[string][]string{ + "https://api.example.com/v1/*": {"GET"}, + "https://api.example.com/v1/users": {"POST", "PUT"}, + "https://*.example.com/public/*": {"GET", "HEAD"}, + "https://admin.*.example.com": {"*"}, + }, + matcher: newURLMatcher(), + } + }) + + DescribeTable("complex pattern and method combinations", + func(url, method string, shouldSucceed bool) { + err := perms.IsRequestAllowed(url, method) + if shouldSucceed { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + // Path wildcards with specific methods + Entry("GET to v1 path", "https://api.example.com/v1/posts", "GET", true), + Entry("POST to v1 path", "https://api.example.com/v1/posts", "POST", false), + Entry("POST to specific users endpoint", "https://api.example.com/v1/users", "POST", true), + Entry("PUT to specific users endpoint", "https://api.example.com/v1/users", "PUT", true), + Entry("DELETE to specific users endpoint", "https://api.example.com/v1/users", "DELETE", false), + + // Subdomain wildcards with specific methods + Entry("GET to public path on subdomain", "https://cdn.example.com/public/assets", "GET", true), + Entry("HEAD to public path on subdomain", "https://static.example.com/public/files", "HEAD", true), + Entry("POST to public path on subdomain", "https://api.example.com/public/upload", "POST", false), + + // Admin subdomain with all methods + Entry("GET to admin subdomain", "https://admin.prod.example.com", "GET", true), + Entry("POST to admin subdomain", "https://admin.staging.example.com", "POST", true), + Entry("DELETE to admin subdomain", "https://admin.dev.example.com", "DELETE", true), + ) + }) + }) +}) diff --git a/plugins/host_http_test.go b/plugins/host_http_test.go new file mode 100644 index 000000000..b6f823a07 --- /dev/null +++ b/plugins/host_http_test.go @@ -0,0 +1,190 @@ +package plugins + +import ( + "context" + "net/http" + "net/http/httptest" + "time" + + hosthttp "github.com/navidrome/navidrome/plugins/host/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("httpServiceImpl", func() { + var ( + svc *httpServiceImpl + ts *httptest.Server + ) + + BeforeEach(func() { + svc = &httpServiceImpl{} + }) + + AfterEach(func() { + if ts != nil { + ts.Close() + } + }) + + It("should handle GET requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test", "ok") + w.WriteHeader(201) + _, _ = w.Write([]byte("hello")) + })) + resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Headers: map[string]string{"A": "B"}, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(201))) + Expect(string(resp.Body)).To(Equal("hello")) + Expect(resp.Headers["X-Test"]).To(Equal("ok")) + }) + + It("should handle POST requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + _, _ = w.Write([]byte("got:" + string(b))) + })) + resp, err := svc.Post(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Body: []byte("abc"), + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(string(resp.Body)).To(Equal("got:abc")) + }) + + It("should handle PUT requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + _, _ = w.Write([]byte("put:" + string(b))) + })) + resp, err := svc.Put(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Body: []byte("xyz"), + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(string(resp.Body)).To(Equal("put:xyz")) + }) + + It("should handle DELETE requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + })) + resp, err := svc.Delete(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(204))) + }) + + It("should handle PATCH requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + _, _ = w.Write([]byte("patch:" + string(b))) + })) + resp, err := svc.Patch(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Body: []byte("test-patch"), + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(string(resp.Body)).To(Equal("patch:test-patch")) + }) + + It("should handle HEAD requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "42") + w.WriteHeader(200) + // HEAD responses shouldn't have a body, but the headers should be present + })) + resp, err := svc.Head(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(200))) + Expect(resp.Headers["Content-Type"]).To(Equal("application/json")) + Expect(resp.Headers["Content-Length"]).To(Equal("42")) + Expect(resp.Body).To(BeEmpty()) // HEAD responses have no body + }) + + It("should handle OPTIONS requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Allow", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS") + w.WriteHeader(200) + })) + resp, err := svc.Options(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(200))) + Expect(resp.Headers["Allow"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")) + Expect(resp.Headers["Access-Control-Allow-Methods"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")) + }) + + It("should handle timeouts and errors", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1, + }) + Expect(err).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Error).To(ContainSubstring("deadline exceeded")) + }) + + It("should return error on context timeout", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + resp, err := svc.Get(ctx, &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Error).To(ContainSubstring("context deadline exceeded")) + }) + + It("should return error on context cancellation", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(1 * time.Millisecond) + cancel() + }() + resp, err := svc.Get(ctx, &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Error).To(ContainSubstring("context canceled")) + }) +}) diff --git a/plugins/host_network_permissions_base.go b/plugins/host_network_permissions_base.go new file mode 100644 index 000000000..c3224fe2a --- /dev/null +++ b/plugins/host_network_permissions_base.go @@ -0,0 +1,192 @@ +package plugins + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" +) + +// NetworkPermissionsBase contains common functionality for network-based permissions +type networkPermissionsBase struct { + Reason string `json:"reason"` + AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty"` +} + +// URLMatcher provides URL pattern matching functionality +type urlMatcher struct{} + +// newURLMatcher creates a new URL matcher instance +func newURLMatcher() *urlMatcher { + return &urlMatcher{} +} + +// checkURLPolicy performs common checks for a URL against network policies. +func checkURLPolicy(requestURL string, allowLocalNetwork bool) (*url.URL, error) { + parsedURL, err := url.Parse(requestURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + // Check local network restrictions + if !allowLocalNetwork { + if err := checkLocalNetwork(parsedURL); err != nil { + return nil, err + } + } + return parsedURL, nil +} + +// MatchesURLPattern checks if a URL matches a given pattern +func (m *urlMatcher) MatchesURLPattern(requestURL, pattern string) bool { + // Handle wildcard pattern + if pattern == "*" { + return true + } + + // Parse both URLs to handle path matching correctly + reqURL, err := url.Parse(requestURL) + if err != nil { + return false + } + + patternURL, err := url.Parse(pattern) + if err != nil { + // If pattern is not a valid URL, treat it as a simple string pattern + regexPattern := m.urlPatternToRegex(pattern) + matched, err := regexp.MatchString(regexPattern, requestURL) + if err != nil { + return false + } + return matched + } + + // Match scheme + if patternURL.Scheme != "" && patternURL.Scheme != reqURL.Scheme { + return false + } + + // Match host with wildcard support + if !m.matchesHost(reqURL.Host, patternURL.Host) { + return false + } + + // Match path with wildcard support + // Special case: if pattern URL has empty path and contains wildcards, allow any path (domain-only wildcard matching) + if (patternURL.Path == "" || patternURL.Path == "/") && strings.Contains(pattern, "*") { + // This is a domain-only wildcard pattern, allow any path + return true + } + if !m.matchesPath(reqURL.Path, patternURL.Path) { + return false + } + + return true +} + +// urlPatternToRegex converts a URL pattern with wildcards to a regex pattern +func (m *urlMatcher) urlPatternToRegex(pattern string) string { + // Escape special regex characters except * + escaped := regexp.QuoteMeta(pattern) + + // Replace escaped \* with regex pattern for wildcard matching + // For subdomain: *.example.com -> [^.]*\.example\.com + // For path: /api/* -> /api/.* + escaped = strings.ReplaceAll(escaped, "\\*", ".*") + + // Anchor the pattern to match the full URL + return "^" + escaped + "$" +} + +// matchesHost checks if a host matches a pattern with wildcard support +func (m *urlMatcher) matchesHost(host, pattern string) bool { + if pattern == "" { + return true + } + + if pattern == "*" { + return true + } + + // Handle wildcard patterns anywhere in the host + if strings.Contains(pattern, "*") { + patterns := []string{ + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[0-9.]+"), // IP pattern + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[^.]*"), // Domain pattern + } + + for _, regexPattern := range patterns { + fullPattern := "^" + regexPattern + "$" + if matched, err := regexp.MatchString(fullPattern, host); err == nil && matched { + return true + } + } + return false + } + + return host == pattern +} + +// matchesPath checks if a path matches a pattern with wildcard support +func (m *urlMatcher) matchesPath(path, pattern string) bool { + // Normalize empty paths to "/" + if path == "" { + path = "/" + } + if pattern == "" { + pattern = "/" + } + + if pattern == "*" { + return true + } + + // Handle wildcard paths + if strings.HasSuffix(pattern, "/*") { + prefix := pattern[:len(pattern)-2] // Remove "/*" + if prefix == "" { + prefix = "/" + } + return strings.HasPrefix(path, prefix) + } + + return path == pattern +} + +// CheckLocalNetwork checks if the URL is accessing local network resources +func checkLocalNetwork(parsedURL *url.URL) error { + host := parsedURL.Hostname() + + // Check for localhost variants + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return fmt.Errorf("requests to localhost are not allowed") + } + + // Try to parse as IP address + ip := net.ParseIP(host) + if ip != nil && isPrivateIP(ip) { + return fmt.Errorf("requests to private IP addresses are not allowed") + } + + return nil +} + +// IsPrivateIP checks if an IP is loopback, private, or link-local (IPv4/IPv6). +func isPrivateIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() { + return true + } + // IPv4 link-local: 169.254.0.0/16 + if ip4 := ip.To4(); ip4 != nil { + return ip4[0] == 169 && ip4[1] == 254 + } + // IPv6 link-local: fe80::/10 + if ip16 := ip.To16(); ip16 != nil && ip.To4() == nil { + return ip16[0] == 0xfe && (ip16[1]&0xc0) == 0x80 + } + return false +} diff --git a/plugins/host_network_permissions_base_test.go b/plugins/host_network_permissions_base_test.go new file mode 100644 index 000000000..9147e99ac --- /dev/null +++ b/plugins/host_network_permissions_base_test.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "net" + "net/url" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("networkPermissionsBase", func() { + Describe("urlMatcher", func() { + var matcher *urlMatcher + + BeforeEach(func() { + matcher = newURLMatcher() + }) + + Describe("MatchesURLPattern", func() { + DescribeTable("exact URL matching", + func(requestURL, pattern string, expected bool) { + result := matcher.MatchesURLPattern(requestURL, pattern) + Expect(result).To(Equal(expected)) + }, + Entry("exact match", "https://api.example.com", "https://api.example.com", true), + Entry("different domain", "https://api.example.com", "https://api.other.com", false), + Entry("different scheme", "http://api.example.com", "https://api.example.com", false), + Entry("different path", "https://api.example.com/v1", "https://api.example.com/v2", false), + ) + + DescribeTable("wildcard pattern matching", + func(requestURL, pattern string, expected bool) { + result := matcher.MatchesURLPattern(requestURL, pattern) + Expect(result).To(Equal(expected)) + }, + Entry("universal wildcard", "https://api.example.com", "*", true), + Entry("subdomain wildcard match", "https://api.example.com", "https://*.example.com", true), + Entry("subdomain wildcard non-match", "https://api.other.com", "https://*.example.com", false), + Entry("path wildcard match", "https://api.example.com/v1/users", "https://api.example.com/*", true), + Entry("path wildcard non-match", "https://other.example.com/v1", "https://api.example.com/*", false), + Entry("port wildcard match", "https://api.example.com:8080", "https://api.example.com:*", true), + ) + }) + }) + + Describe("isPrivateIP", func() { + DescribeTable("IPv4 private IP detection", + func(ip string, expected bool) { + parsedIP := net.ParseIP(ip) + Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip) + result := isPrivateIP(parsedIP) + Expect(result).To(Equal(expected)) + }, + // Private IPv4 ranges + Entry("10.0.0.1 (10.0.0.0/8)", "10.0.0.1", true), + Entry("10.255.255.255 (10.0.0.0/8)", "10.255.255.255", true), + Entry("172.16.0.1 (172.16.0.0/12)", "172.16.0.1", true), + Entry("172.31.255.255 (172.16.0.0/12)", "172.31.255.255", true), + Entry("192.168.1.1 (192.168.0.0/16)", "192.168.1.1", true), + Entry("192.168.255.255 (192.168.0.0/16)", "192.168.255.255", true), + Entry("127.0.0.1 (localhost)", "127.0.0.1", true), + Entry("127.255.255.255 (localhost)", "127.255.255.255", true), + Entry("169.254.1.1 (link-local)", "169.254.1.1", true), + Entry("169.254.255.255 (link-local)", "169.254.255.255", true), + + // Public IPv4 addresses + Entry("8.8.8.8 (Google DNS)", "8.8.8.8", false), + Entry("1.1.1.1 (Cloudflare DNS)", "1.1.1.1", false), + Entry("208.67.222.222 (OpenDNS)", "208.67.222.222", false), + Entry("172.15.255.255 (just outside 172.16.0.0/12)", "172.15.255.255", false), + Entry("172.32.0.1 (just outside 172.16.0.0/12)", "172.32.0.1", false), + ) + + DescribeTable("IPv6 private IP detection", + func(ip string, expected bool) { + parsedIP := net.ParseIP(ip) + Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip) + result := isPrivateIP(parsedIP) + Expect(result).To(Equal(expected)) + }, + // Private IPv6 ranges + Entry("::1 (IPv6 localhost)", "::1", true), + Entry("fe80::1 (link-local)", "fe80::1", true), + Entry("fc00::1 (unique local)", "fc00::1", true), + Entry("fd00::1 (unique local)", "fd00::1", true), + + // Public IPv6 addresses + Entry("2001:4860:4860::8888 (Google DNS)", "2001:4860:4860::8888", false), + Entry("2606:4700:4700::1111 (Cloudflare DNS)", "2606:4700:4700::1111", false), + ) + }) + + Describe("checkLocalNetwork", func() { + DescribeTable("local network detection", + func(urlStr string, shouldError bool, expectedErrorSubstring string) { + parsedURL, err := url.Parse(urlStr) + Expect(err).ToNot(HaveOccurred()) + + err = checkLocalNetwork(parsedURL) + if shouldError { + Expect(err).To(HaveOccurred()) + if expectedErrorSubstring != "" { + Expect(err.Error()).To(ContainSubstring(expectedErrorSubstring)) + } + } else { + Expect(err).ToNot(HaveOccurred()) + } + }, + Entry("localhost", "http://localhost:8080", true, "localhost"), + Entry("127.0.0.1", "http://127.0.0.1:3000", true, "localhost"), + Entry("::1", "http://[::1]:8080", true, "localhost"), + Entry("private IP 192.168.1.100", "http://192.168.1.100", true, "private IP"), + Entry("private IP 10.0.0.1", "http://10.0.0.1", true, "private IP"), + Entry("private IP 172.16.0.1", "http://172.16.0.1", true, "private IP"), + Entry("public IP 8.8.8.8", "http://8.8.8.8", false, ""), + Entry("public domain", "https://api.example.com", false, ""), + ) + }) +}) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go new file mode 100644 index 000000000..6cea93280 --- /dev/null +++ b/plugins/host_scheduler.go @@ -0,0 +1,347 @@ +package plugins + +import ( + "context" + "fmt" + "sync" + "time" + + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/scheduler" + navidsched "github.com/navidrome/navidrome/scheduler" +) + +const ( + ScheduleTypeOneTime = "one-time" + ScheduleTypeRecurring = "recurring" +) + +// ScheduledCallback represents a registered schedule callback +type ScheduledCallback struct { + ID string + PluginID string + Type string // "one-time" or "recurring" + Payload []byte + EntryID int // Used for recurring schedules via the scheduler + Cancel context.CancelFunc // Used for one-time schedules +} + +// SchedulerHostFunctions implements the scheduler.SchedulerService interface +type SchedulerHostFunctions struct { + ss *schedulerService + pluginID string +} + +func (s SchedulerHostFunctions) ScheduleOneTime(ctx context.Context, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) { + return s.ss.scheduleOneTime(ctx, s.pluginID, req) +} + +func (s SchedulerHostFunctions) ScheduleRecurring(ctx context.Context, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) { + return s.ss.scheduleRecurring(ctx, s.pluginID, req) +} + +func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) { + return s.ss.cancelSchedule(ctx, s.pluginID, req) +} + +type schedulerService struct { + // Map of schedule IDs to their callback info + schedules map[string]*ScheduledCallback + manager *Manager + navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs + mu sync.Mutex +} + +// newSchedulerService creates a new schedulerService instance +func newSchedulerService(manager *Manager) *schedulerService { + return &schedulerService{ + schedules: make(map[string]*ScheduledCallback), + manager: manager, + navidSched: navidsched.GetInstance(), + } +} + +func (s *schedulerService) HostFunctions(pluginID string) SchedulerHostFunctions { + return SchedulerHostFunctions{ + ss: s, + pluginID: pluginID, + } +} + +// Safe accessor methods for tests + +// hasSchedule safely checks if a schedule exists +func (s *schedulerService) hasSchedule(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, exists := s.schedules[id] + return exists +} + +// scheduleCount safely returns the number of schedules +func (s *schedulerService) scheduleCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.schedules) +} + +// getScheduleType safely returns the type of a schedule +func (s *schedulerService) getScheduleType(id string) string { + s.mu.Lock() + defer s.mu.Unlock() + if cb, exists := s.schedules[id]; exists { + return cb.Type + } + return "" +} + +// scheduleJob is a helper function that handles the common logic for scheduling jobs +func (s *schedulerService) scheduleJob(pluginID string, scheduleId string, jobType string, payload []byte) (string, *ScheduledCallback, context.CancelFunc, error) { + if s.manager == nil { + return "", nil, nil, fmt.Errorf("scheduler service not properly initialized") + } + + // Original scheduleId (what the plugin will see) + originalScheduleId := scheduleId + if originalScheduleId == "" { + // Generate a random ID if one wasn't provided + originalScheduleId, _ = gonanoid.New(10) + } + + // Internal scheduleId (prefixed with plugin name to avoid conflicts) + internalScheduleId := pluginID + ":" + originalScheduleId + + // Store any existing cancellation function to call after we've updated the map + var cancelExisting context.CancelFunc + + // Check if there's an existing schedule with the same ID, we'll cancel it after updating the map + if existingSchedule, ok := s.schedules[internalScheduleId]; ok { + log.Debug("Replacing existing schedule with same ID", "plugin", pluginID, "scheduleID", originalScheduleId) + + // Store cancel information but don't call it yet + if existingSchedule.Type == ScheduleTypeOneTime && existingSchedule.Cancel != nil { + // We'll set the Cancel to nil to prevent the old job from removing the new one + cancelExisting = existingSchedule.Cancel + existingSchedule.Cancel = nil + } else if existingSchedule.Type == ScheduleTypeRecurring { + existingRecurringEntryID := existingSchedule.EntryID + if existingRecurringEntryID != 0 { + s.navidSched.Remove(existingRecurringEntryID) + } + } + } + + // Create the callback object + callback := &ScheduledCallback{ + ID: originalScheduleId, + PluginID: pluginID, + Type: jobType, + Payload: payload, + } + + return internalScheduleId, callback, cancelExisting, nil +} + +// scheduleOneTime registers a new one-time scheduled job +func (s *schedulerService) scheduleOneTime(_ context.Context, pluginID string, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeOneTime, req.Payload) + if err != nil { + return nil, err + } + + // Create a context with cancel for this one-time schedule + scheduleCtx, cancel := context.WithCancel(context.Background()) + callback.Cancel = cancel + + // Store the callback info + s.schedules[internalScheduleId] = callback + + // Now that the new job is in the map, we can safely cancel the old one + if cancelExisting != nil { + // Cancel in a goroutine to avoid deadlock since we're already holding the lock + go cancelExisting() + } + + log.Debug("One-time schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId) + + // Start the timer goroutine with the internal ID + go s.runOneTimeSchedule(scheduleCtx, internalScheduleId, time.Duration(req.DelaySeconds)*time.Second) + + // Return the original ID to the plugin + return &scheduler.ScheduleResponse{ + ScheduleId: callback.ID, + }, nil +} + +// scheduleRecurring registers a new recurring scheduled job +func (s *schedulerService) scheduleRecurring(_ context.Context, pluginID string, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeRecurring, req.Payload) + if err != nil { + return nil, err + } + + // Schedule the job with the Navidrome scheduler + entryID, err := s.navidSched.Add(req.CronExpression, func() { + s.executeCallback(context.Background(), internalScheduleId, true) + }) + if err != nil { + return nil, fmt.Errorf("failed to schedule recurring job: %w", err) + } + + // Store the entry ID so we can cancel it later + callback.EntryID = entryID + + // Store the callback info + s.schedules[internalScheduleId] = callback + + // Now that the new job is in the map, we can safely cancel the old one + if cancelExisting != nil { + // Cancel in a goroutine to avoid deadlock since we're already holding the lock + go cancelExisting() + } + + log.Debug("Recurring schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId, "cron", req.CronExpression) + + // Return the original ID to the plugin + return &scheduler.ScheduleResponse{ + ScheduleId: callback.ID, + }, nil +} + +// cancelSchedule cancels a scheduled job (either one-time or recurring) +func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + internalScheduleId := pluginID + ":" + req.ScheduleId + callback, exists := s.schedules[internalScheduleId] + if !exists { + return &scheduler.CancelResponse{ + Success: false, + Error: "schedule not found", + }, nil + } + + // Store the cancel functions to call after we've updated the schedule map + var cancelFunc context.CancelFunc + var recurringEntryID int + + // Store cancel information but don't call it yet + if callback.Type == ScheduleTypeOneTime && callback.Cancel != nil { + cancelFunc = callback.Cancel + callback.Cancel = nil // Set to nil to prevent the cancel handler from removing the job + } else if callback.Type == ScheduleTypeRecurring { + recurringEntryID = callback.EntryID + } + + // First remove from the map + delete(s.schedules, internalScheduleId) + + // Now perform the cancellation safely + if cancelFunc != nil { + // Execute in a goroutine to avoid deadlock since we're already holding the lock + go cancelFunc() + } + if recurringEntryID != 0 { + s.navidSched.Remove(recurringEntryID) + } + + log.Debug("Schedule canceled", "plugin", pluginID, "scheduleID", req.ScheduleId, "internalID", internalScheduleId, "type", callback.Type) + + return &scheduler.CancelResponse{ + Success: true, + }, nil +} + +// runOneTimeSchedule handles the one-time schedule execution and callback +func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) { + tmr := time.NewTimer(delay) + defer tmr.Stop() + + select { + case <-ctx.Done(): + // Schedule was cancelled via its context + // We're no longer removing the schedule here because that's handled by the code that + // cancelled the context + log.Debug("One-time schedule context canceled", "internalID", internalScheduleId) + return + + case <-tmr.C: + // Timer fired, execute the callback + s.executeCallback(ctx, internalScheduleId, false) + } +} + +// executeCallback calls the plugin's OnSchedulerCallback method +func (s *schedulerService) executeCallback(ctx context.Context, internalScheduleId string, isRecurring bool) { + s.mu.Lock() + callback := s.schedules[internalScheduleId] + // Only remove one-time schedules from the map after execution + if callback != nil && callback.Type == ScheduleTypeOneTime { + delete(s.schedules, internalScheduleId) + } + s.mu.Unlock() + + if callback == nil { + log.Error("Schedule not found for callback", "internalID", internalScheduleId) + return + } + + callbackType := "one-time" + if isRecurring { + callbackType = "recurring" + } + + log.Debug("Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType) + start := time.Now() + + // Create a SchedulerCallbackRequest + req := &api.SchedulerCallbackRequest{ + ScheduleId: callback.ID, + Payload: callback.Payload, + IsRecurring: isRecurring, + } + + // Get the plugin + p := s.manager.LoadPlugin(callback.PluginID, CapabilitySchedulerCallback) + if p == nil { + log.Error("Plugin not found for callback", "plugin", callback.PluginID) + return + } + + // Get instance + inst, closeFn, err := p.Instantiate(ctx) + if err != nil { + log.Error("Error getting plugin instance for callback", "plugin", callback.PluginID, err) + return + } + defer closeFn() + + // Type-check the plugin + plugin, ok := inst.(api.SchedulerCallback) + if !ok { + log.Error("Plugin does not implement SchedulerCallback", "plugin", callback.PluginID) + return + } + + // Call the plugin's OnSchedulerCallback method + log.Trace(ctx, "Executing schedule callback", "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callbackType) + resp, err := plugin.OnSchedulerCallback(ctx, req) + if err != nil { + log.Error("Error executing schedule callback", "plugin", callback.PluginID, "elapsed", time.Since(start), err) + return + } + log.Debug("Schedule callback executed", "plugin", callback.PluginID, "elapsed", time.Since(start)) + + if resp.Error != "" { + log.Error("Plugin reported error in schedule callback", "plugin", callback.PluginID, resp.Error) + } +} diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go new file mode 100644 index 000000000..f544d716e --- /dev/null +++ b/plugins/host_scheduler_test.go @@ -0,0 +1,166 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/host/scheduler" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SchedulerService", func() { + var ( + ss *schedulerService + manager *Manager + pluginName = "test_plugin" + ) + + BeforeEach(func() { + manager = createManager(nil, nil) + ss = manager.schedulerService + }) + + Describe("One-time scheduling", func() { + It("schedules one-time jobs successfully", func() { + req := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 1, + Payload: []byte("test payload"), + ScheduleId: "test-job", + } + + resp, err := ss.scheduleOneTime(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).To(Equal("test-job")) + Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeTrue()) + Expect(ss.getScheduleType(pluginName + ":" + "test-job")).To(Equal(ScheduleTypeOneTime)) + + // Test auto-generated ID + req.ScheduleId = "" + resp, err = ss.scheduleOneTime(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).ToNot(BeEmpty()) + }) + + It("cancels one-time jobs successfully", func() { + req := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 10, + ScheduleId: "test-job", + } + + _, err := ss.scheduleOneTime(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + + cancelReq := &scheduler.CancelRequest{ + ScheduleId: "test-job", + } + + resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Success).To(BeTrue()) + Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeFalse()) + }) + }) + + Describe("Recurring scheduling", func() { + It("schedules recurring jobs successfully", func() { + req := &scheduler.ScheduleRecurringRequest{ + CronExpression: "* * * * *", // Every minute + Payload: []byte("test payload"), + ScheduleId: "test-cron", + } + + resp, err := ss.scheduleRecurring(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).To(Equal("test-cron")) + Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeTrue()) + Expect(ss.getScheduleType(pluginName + ":" + "test-cron")).To(Equal(ScheduleTypeRecurring)) + + // Test auto-generated ID + req.ScheduleId = "" + resp, err = ss.scheduleRecurring(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).ToNot(BeEmpty()) + }) + + It("cancels recurring jobs successfully", func() { + req := &scheduler.ScheduleRecurringRequest{ + CronExpression: "* * * * *", // Every minute + ScheduleId: "test-cron", + } + + _, err := ss.scheduleRecurring(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + + cancelReq := &scheduler.CancelRequest{ + ScheduleId: "test-cron", + } + + resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Success).To(BeTrue()) + Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeFalse()) + }) + }) + + Describe("Replace existing schedules", func() { + It("replaces one-time jobs with new ones", func() { + // Create first job + req1 := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 10, + Payload: []byte("test payload 1"), + ScheduleId: "replace-job", + } + _, err := ss.scheduleOneTime(context.Background(), pluginName, req1) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the initial job exists + scheduleId := pluginName + ":" + "replace-job" + Expect(ss.hasSchedule(scheduleId)).To(BeTrue(), "Initial schedule should exist") + + beforeCount := ss.scheduleCount() + + // Replace with second job using same ID + req2 := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 60, // Use a longer delay to ensure it doesn't execute during the test + Payload: []byte("test payload 2"), + ScheduleId: "replace-job", + } + + _, err = ss.scheduleOneTime(context.Background(), pluginName, req2) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return ss.hasSchedule(scheduleId) + }).Should(BeTrue(), "Schedule should exist after replacement") + Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement") + }) + + It("replaces recurring jobs with new ones", func() { + // Create first job + req1 := &scheduler.ScheduleRecurringRequest{ + CronExpression: "0 * * * *", + Payload: []byte("test payload 1"), + ScheduleId: "replace-cron", + } + _, err := ss.scheduleRecurring(context.Background(), pluginName, req1) + Expect(err).ToNot(HaveOccurred()) + + beforeCount := ss.scheduleCount() + + // Replace with second job using same ID + req2 := &scheduler.ScheduleRecurringRequest{ + CronExpression: "*/5 * * * *", + Payload: []byte("test payload 2"), + ScheduleId: "replace-cron", + } + + _, err = ss.scheduleRecurring(context.Background(), pluginName, req2) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return ss.hasSchedule(pluginName + ":" + "replace-cron") + }).Should(BeTrue(), "Schedule should exist after replacement") + Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement") + }) + }) +}) diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go new file mode 100644 index 000000000..d3008798a --- /dev/null +++ b/plugins/host_subsonicapi.go @@ -0,0 +1,166 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/server/subsonic" +) + +// SubsonicAPIService is the interface for the Subsonic API service +// +// Authentication: The plugin must provide valid authentication parameters in the URL: +// - Required: `u` (username) - The service validates this parameter is present +// - Example: `"/rest/ping?u=admin"` +// +// URL Format: Only the path and query parameters from the URL are used - host, protocol, and method are ignored +// +// Automatic Parameters: The service automatically adds: +// - `c`: Plugin name (client identifier) +// - `v`: Subsonic API version (1.16.1) +// - `f`: Response format (json) +// +// See example usage in the `plugins/examples/subsonicapi-demo` plugin +type subsonicAPIServiceImpl struct { + pluginID string + router SubsonicRouter + ds model.DataStore + permissions *subsonicAPIPermissions +} + +func newSubsonicAPIService(pluginID string, router *SubsonicRouter, ds model.DataStore, permissions *schema.PluginManifestPermissionsSubsonicapi) subsonicapi.SubsonicAPIService { + return &subsonicAPIServiceImpl{ + pluginID: pluginID, + router: *router, + ds: ds, + permissions: parseSubsonicAPIPermissions(permissions), + } +} + +func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.CallRequest) (*subsonicapi.CallResponse, error) { + if s.router == nil { + return &subsonicapi.CallResponse{ + Error: "SubsonicAPI router not available", + }, nil + } + + // Parse the input URL + parsedURL, err := url.Parse(req.Url) + if err != nil { + return &subsonicapi.CallResponse{ + Error: fmt.Sprintf("invalid URL format: %v", err), + }, nil + } + + // Extract query parameters + query := parsedURL.Query() + + // Validate that 'u' (username) parameter is present + username := query.Get("u") + if username == "" { + return &subsonicapi.CallResponse{ + Error: "missing required parameter 'u' (username)", + }, nil + } + + if err := s.checkPermissions(ctx, username); err != nil { + log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) + return &subsonicapi.CallResponse{Error: err.Error()}, nil + } + + // Add required Subsonic API parameters + query.Set("c", s.pluginID) // Client name (plugin ID) + query.Set("f", "json") // Response format + query.Set("v", subsonic.Version) // API version + + // Extract the endpoint from the path + endpoint := path.Base(parsedURL.Path) + + // Build the final URL with processed path and modified query parameters + finalURL := &url.URL{ + Path: "/" + endpoint, + RawQuery: query.Encode(), + } + + // Create HTTP request with internal authentication + httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil) + if err != nil { + return &subsonicapi.CallResponse{ + Error: fmt.Sprintf("failed to create HTTP request: %v", err), + }, nil + } + + // Set internal authentication context using the username from the 'u' parameter + authCtx := request.WithInternalAuth(httpReq.Context(), username) + httpReq = httpReq.WithContext(authCtx) + + // Use ResponseRecorder to capture the response + recorder := httptest.NewRecorder() + + // Call the subsonic router + s.router.ServeHTTP(recorder, httpReq) + + // Return the response body as JSON + return &subsonicapi.CallResponse{ + Json: recorder.Body.String(), + }, nil +} + +func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { + if s.permissions == nil { + return nil + } + if len(s.permissions.AllowedUsernames) > 0 { + if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok { + return fmt.Errorf("username %s is not allowed", username) + } + } + if !s.permissions.AllowAdmins { + if s.router == nil { + return fmt.Errorf("permissions check failed: router not available") + } + usr, err := s.ds.User(ctx).FindByUsername(username) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("username %s not found", username) + } + return err + } + if usr.IsAdmin { + return fmt.Errorf("calling SubsonicAPI as admin user is not allowed") + } + } + return nil +} + +type subsonicAPIPermissions struct { + AllowedUsernames []string + AllowAdmins bool + usernameMap map[string]struct{} +} + +func parseSubsonicAPIPermissions(data *schema.PluginManifestPermissionsSubsonicapi) *subsonicAPIPermissions { + if data == nil { + return &subsonicAPIPermissions{} + } + perms := &subsonicAPIPermissions{ + AllowedUsernames: data.AllowedUsernames, + AllowAdmins: data.AllowAdmins, + usernameMap: make(map[string]struct{}), + } + for _, u := range data.AllowedUsernames { + perms.usernameMap[strings.ToLower(u)] = struct{}{} + } + return perms +} diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go new file mode 100644 index 000000000..a3161ff06 --- /dev/null +++ b/plugins/host_subsonicapi_test.go @@ -0,0 +1,218 @@ +package plugins + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SubsonicAPI Host Service", func() { + var ( + service *subsonicAPIServiceImpl + mockRouter http.Handler + userRepo *tests.MockedUserRepo + ) + + BeforeEach(func() { + // Setup mock datastore with users + userRepo = tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{UserName: "admin", IsAdmin: true}) + _ = userRepo.Put(&model.User{UserName: "user", IsAdmin: false}) + ds := &tests.MockDataStore{MockedUser: userRepo} + + // Create a mock router + mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"subsonic-response":{"status":"ok","version":"1.16.1"}}`)) + }) + + // Create service implementation + service = &subsonicAPIServiceImpl{ + pluginID: "test-plugin", + router: mockRouter, + ds: ds, + } + }) + + // Helper function to create a mock router that captures the request + setupRequestCapture := func() **http.Request { + var capturedRequest *http.Request + mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRequest = r + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + service.router = mockRouter + return &capturedRequest + } + + Describe("Call", func() { + Context("when subsonic router is available", func() { + It("should process the request successfully", func() { + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Json).To(ContainSubstring("subsonic-response")) + Expect(resp.Json).To(ContainSubstring("ok")) + }) + + It("should add required parameters to the URL", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "/rest/getAlbum.view?id=123&u=admin", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + + query := (*capturedRequestPtr).URL.Query() + Expect(query.Get("c")).To(Equal("test-plugin")) + Expect(query.Get("f")).To(Equal("json")) + Expect(query.Get("v")).To(Equal("1.16.1")) + Expect(query.Get("id")).To(Equal("123")) + Expect(query.Get("u")).To(Equal("admin")) + }) + + It("should only use path and query from the input URL", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "https://external.example.com:8080/rest/ping?u=admin", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping")) + Expect((*capturedRequestPtr).URL.Host).To(BeEmpty()) + Expect((*capturedRequestPtr).URL.Scheme).To(BeEmpty()) + }) + + It("ignores the path prefix in the URL", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "/basepath/rest/ping?u=admin", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping")) + }) + + It("should set internal authentication with username from 'u' parameter", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?u=testuser", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + + // Verify that internal authentication is set in the context + username, ok := request.InternalAuthFrom((*capturedRequestPtr).Context()) + Expect(ok).To(BeTrue()) + Expect(username).To(Equal("testuser")) + }) + }) + + Context("when subsonic router is not available", func() { + BeforeEach(func() { + service.router = nil + }) + + It("should return an error", func() { + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(Equal("SubsonicAPI router not available")) + Expect(resp.Json).To(BeEmpty()) + }) + }) + + Context("when URL is invalid", func() { + It("should return an error for malformed URLs", func() { + req := &subsonicapi.CallRequest{ + Url: "://invalid-url", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(ContainSubstring("invalid URL format")) + Expect(resp.Json).To(BeEmpty()) + }) + + It("should return an error when 'u' parameter is missing", func() { + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?p=password", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(Equal("missing required parameter 'u' (username)")) + Expect(resp.Json).To(BeEmpty()) + }) + }) + + Context("permission checks", func() { + It("rejects disallowed username", func() { + service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{ + Reason: "test", + AllowedUsernames: []string{"user"}, + }) + + resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(ContainSubstring("not allowed")) + }) + + It("rejects admin when allowAdmins is false", func() { + service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test"}) + + resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(ContainSubstring("not allowed")) + }) + + It("allows admin when allowAdmins is true", func() { + service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test", AllowAdmins: true}) + + resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeEmpty()) + }) + }) + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go new file mode 100644 index 000000000..131596b94 --- /dev/null +++ b/plugins/host_websocket.go @@ -0,0 +1,414 @@ +package plugins + +import ( + "context" + "encoding/binary" + "fmt" + "strings" + "sync" + "time" + + gorillaws "github.com/gorilla/websocket" + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/websocket" +) + +// WebSocketConnection represents a WebSocket connection +type WebSocketConnection struct { + Conn *gorillaws.Conn + PluginName string + ConnectionID string + Done chan struct{} + mu sync.Mutex +} + +// WebSocketHostFunctions implements the websocket.WebSocketService interface +type WebSocketHostFunctions struct { + ws *websocketService + pluginID string + permissions *webSocketPermissions +} + +func (s WebSocketHostFunctions) Connect(ctx context.Context, req *websocket.ConnectRequest) (*websocket.ConnectResponse, error) { + return s.ws.connect(ctx, s.pluginID, req, s.permissions) +} + +func (s WebSocketHostFunctions) SendText(ctx context.Context, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) { + return s.ws.sendText(ctx, s.pluginID, req) +} + +func (s WebSocketHostFunctions) SendBinary(ctx context.Context, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) { + return s.ws.sendBinary(ctx, s.pluginID, req) +} + +func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseRequest) (*websocket.CloseResponse, error) { + return s.ws.close(ctx, s.pluginID, req) +} + +// websocketService implements the WebSocket service functionality +type websocketService struct { + connections map[string]*WebSocketConnection + manager *Manager + mu sync.RWMutex +} + +// newWebsocketService creates a new websocketService instance +func newWebsocketService(manager *Manager) *websocketService { + return &websocketService{ + connections: make(map[string]*WebSocketConnection), + manager: manager, + } +} + +// HostFunctions returns the WebSocketHostFunctions for the given plugin +func (s *websocketService) HostFunctions(pluginID string, permissions *webSocketPermissions) WebSocketHostFunctions { + return WebSocketHostFunctions{ + ws: s, + pluginID: pluginID, + permissions: permissions, + } +} + +// Safe accessor methods + +// hasConnection safely checks if a connection exists +func (s *websocketService) hasConnection(id string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, exists := s.connections[id] + return exists +} + +// connectionCount safely returns the number of connections +func (s *websocketService) connectionCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.connections) +} + +// getConnection safely retrieves a connection by internal ID +func (s *websocketService) getConnection(internalConnectionID string) (*WebSocketConnection, error) { + s.mu.RLock() + defer s.mu.RUnlock() + conn, exists := s.connections[internalConnectionID] + + if !exists { + return nil, fmt.Errorf("connection not found") + } + return conn, nil +} + +// internalConnectionID builds the internal connection ID from plugin and connection ID +func internalConnectionID(pluginName, connectionID string) string { + return pluginName + ":" + connectionID +} + +// extractConnectionID extracts the original connection ID from an internal ID +func extractConnectionID(internalID string) (string, error) { + parts := strings.Split(internalID, ":") + if len(parts) != 2 { + return "", fmt.Errorf("invalid internal connection ID format: %s", internalID) + } + return parts[1], nil +} + +// connect establishes a new WebSocket connection +func (s *websocketService) connect(ctx context.Context, pluginID string, req *websocket.ConnectRequest, permissions *webSocketPermissions) (*websocket.ConnectResponse, error) { + if s.manager == nil { + return nil, fmt.Errorf("websocket service not properly initialized") + } + + // Check permissions if they exist + if permissions != nil { + if err := permissions.IsConnectionAllowed(req.Url); err != nil { + log.Warn(ctx, "WebSocket connection blocked by permissions", "plugin", pluginID, "url", req.Url, err) + return &websocket.ConnectResponse{Error: "Connection blocked by plugin permissions: " + err.Error()}, nil + } + } + + // Create websocket dialer with the headers + dialer := gorillaws.DefaultDialer + header := make(map[string][]string) + for k, v := range req.Headers { + header[k] = []string{v} + } + + // Connect to the WebSocket server + conn, resp, err := dialer.DialContext(ctx, req.Url, header) + if err != nil { + return nil, fmt.Errorf("failed to connect to WebSocket server: %w", err) + } + defer resp.Body.Close() + + // Generate a connection ID + if req.ConnectionId == "" { + req.ConnectionId, _ = gonanoid.New(10) + } + connectionID := req.ConnectionId + internal := internalConnectionID(pluginID, connectionID) + + // Create the connection object + wsConn := &WebSocketConnection{ + Conn: conn, + PluginName: pluginID, + ConnectionID: connectionID, + Done: make(chan struct{}), + } + + // Store the connection + s.mu.Lock() + defer s.mu.Unlock() + s.connections[internal] = wsConn + + log.Debug("WebSocket connection established", "plugin", pluginID, "connectionID", connectionID, "url", req.Url) + + // Start the message handling goroutine + go s.handleMessages(internal, wsConn) + + return &websocket.ConnectResponse{ + ConnectionId: connectionID, + }, nil +} + +// writeMessage is a helper to send messages to a websocket connection +func (s *websocketService) writeMessage(pluginID string, connID string, messageType int, data []byte) error { + internal := internalConnectionID(pluginID, connID) + + conn, err := s.getConnection(internal) + if err != nil { + return err + } + + conn.mu.Lock() + defer conn.mu.Unlock() + + if err := conn.Conn.WriteMessage(messageType, data); err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil +} + +// sendText sends a text message over a WebSocket connection +func (s *websocketService) sendText(ctx context.Context, pluginID string, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) { + if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.TextMessage, []byte(req.Message)); err != nil { + return &websocket.SendTextResponse{Error: err.Error()}, nil //nolint:nilerr + } + return &websocket.SendTextResponse{}, nil +} + +// sendBinary sends binary data over a WebSocket connection +func (s *websocketService) sendBinary(ctx context.Context, pluginID string, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) { + if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.BinaryMessage, req.Data); err != nil { + return &websocket.SendBinaryResponse{Error: err.Error()}, nil //nolint:nilerr + } + return &websocket.SendBinaryResponse{}, nil +} + +// close closes a WebSocket connection +func (s *websocketService) close(ctx context.Context, pluginID string, req *websocket.CloseRequest) (*websocket.CloseResponse, error) { + internal := internalConnectionID(pluginID, req.ConnectionId) + + s.mu.Lock() + conn, exists := s.connections[internal] + if !exists { + s.mu.Unlock() + return &websocket.CloseResponse{Error: "connection not found"}, nil + } + delete(s.connections, internal) + s.mu.Unlock() + + // Signal the message handling goroutine to stop + close(conn.Done) + + // Close the connection with the specified code and reason + conn.mu.Lock() + defer conn.mu.Unlock() + + err := conn.Conn.WriteControl( + gorillaws.CloseMessage, + gorillaws.FormatCloseMessage(int(req.Code), req.Reason), + time.Now().Add(time.Second), + ) + if err != nil { + log.Error("Error sending close message", "plugin", pluginID, "error", err) + } + + if err := conn.Conn.Close(); err != nil { + return nil, fmt.Errorf("error closing connection: %w", err) + } + + log.Debug("WebSocket connection closed", "plugin", pluginID, "connectionID", req.ConnectionId) + return &websocket.CloseResponse{}, nil +} + +// handleMessages processes incoming WebSocket messages +func (s *websocketService) handleMessages(internalID string, conn *WebSocketConnection) { + // Get the original connection ID (without plugin prefix) + connectionID, err := extractConnectionID(internalID) + if err != nil { + log.Error("Invalid internal connection ID", "id", internalID, "error", err) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + defer func() { + // Ensure the connection is removed from the map if not already removed + s.mu.Lock() + defer s.mu.Unlock() + delete(s.connections, internalID) + + log.Debug("WebSocket message handler stopped", "plugin", conn.PluginName, "connectionID", connectionID) + }() + + // Add connection info to context + ctx = log.NewContext(ctx, + "connectionID", connectionID, + "plugin", conn.PluginName, + ) + + for { + select { + case <-conn.Done: + // Connection was closed by a Close call + return + default: + // Set a read deadline + _ = conn.Conn.SetReadDeadline(time.Now().Add(time.Second * 60)) + + // Read the next message + messageType, message, err := conn.Conn.ReadMessage() + if err != nil { + s.notifyErrorCallback(ctx, connectionID, conn, err.Error()) + return + } + + // Reset the read deadline + _ = conn.Conn.SetReadDeadline(time.Time{}) + + // Process the message based on its type + switch messageType { + case gorillaws.TextMessage: + s.notifyTextCallback(ctx, connectionID, conn, string(message)) + case gorillaws.BinaryMessage: + s.notifyBinaryCallback(ctx, connectionID, conn, message) + case gorillaws.CloseMessage: + code := gorillaws.CloseNormalClosure + reason := "" + if len(message) >= 2 { + code = int(binary.BigEndian.Uint16(message[:2])) + if len(message) > 2 { + reason = string(message[2:]) + } + } + s.notifyCloseCallback(ctx, connectionID, conn, code, reason) + return + } + } + } +} + +// executeCallback is a common function that handles the plugin loading and execution +// for all types of callbacks +func (s *websocketService) executeCallback(ctx context.Context, pluginID string, fn func(context.Context, api.WebSocketCallback) error) { + log.Debug(ctx, "WebSocket received") + + start := time.Now() + + // Get the plugin + p := s.manager.LoadPlugin(pluginID, CapabilityWebSocketCallback) + if p == nil { + log.Error(ctx, "Plugin not found for WebSocket callback") + return + } + + // Get instance + inst, closeFn, err := p.Instantiate(ctx) + if err != nil { + log.Error(ctx, "Error getting plugin instance for WebSocket callback", err) + return + } + defer closeFn() + + // Type-check the plugin + plugin, ok := inst.(api.WebSocketCallback) + if !ok { + log.Error(ctx, "Plugin does not implement WebSocketCallback") + return + } + + // Call the appropriate callback function + log.Trace(ctx, "Executing WebSocket callback") + + if err = fn(ctx, plugin); err != nil { + log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err) + return + } + + log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start)) +} + +// notifyTextCallback notifies the plugin of a text message +func (s *websocketService) notifyTextCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, message string) { + req := &api.OnTextMessageRequest{ + ConnectionId: connectionID, + Message: message, + } + + ctx = log.NewContext(ctx, "callback", "OnTextMessage", "size", len(message)) + + s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := plugin.OnTextMessage(ctx, req) + return err + }) +} + +// notifyBinaryCallback notifies the plugin of a binary message +func (s *websocketService) notifyBinaryCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, data []byte) { + req := &api.OnBinaryMessageRequest{ + ConnectionId: connectionID, + Data: data, + } + + ctx = log.NewContext(ctx, "callback", "OnBinaryMessage", "size", len(data)) + + s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := plugin.OnBinaryMessage(ctx, req) + return err + }) +} + +// notifyErrorCallback notifies the plugin of an error +func (s *websocketService) notifyErrorCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, errorMsg string) { + req := &api.OnErrorRequest{ + ConnectionId: connectionID, + Error: errorMsg, + } + + ctx = log.NewContext(ctx, "callback", "OnError", "error", errorMsg) + + s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := plugin.OnError(ctx, req) + return err + }) +} + +// notifyCloseCallback notifies the plugin that the connection was closed +func (s *websocketService) notifyCloseCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, code int, reason string) { + req := &api.OnCloseRequest{ + ConnectionId: connectionID, + Code: int32(code), + Reason: reason, + } + + ctx = log.NewContext(ctx, "callback", "OnClose", "code", code, "reason", reason) + + s.executeCallback(ctx, conn.PluginName, func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := plugin.OnClose(ctx, req) + return err + }) +} diff --git a/plugins/host_websocket_permissions.go b/plugins/host_websocket_permissions.go new file mode 100644 index 000000000..53f6a127b --- /dev/null +++ b/plugins/host_websocket_permissions.go @@ -0,0 +1,76 @@ +package plugins + +import ( + "fmt" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// WebSocketPermissions represents granular WebSocket access permissions for plugins +type webSocketPermissions struct { + *networkPermissionsBase + AllowedUrls []string `json:"allowedUrls"` + matcher *urlMatcher +} + +// parseWebSocketPermissions extracts WebSocket permissions from the schema +func parseWebSocketPermissions(permData *schema.PluginManifestPermissionsWebsocket) (*webSocketPermissions, error) { + if len(permData.AllowedUrls) == 0 { + return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern") + } + + return &webSocketPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + AllowLocalNetwork: permData.AllowLocalNetwork, + }, + AllowedUrls: permData.AllowedUrls, + matcher: newURLMatcher(), + }, nil +} + +// IsConnectionAllowed checks if a WebSocket connection is allowed +func (w *webSocketPermissions) IsConnectionAllowed(requestURL string) error { + if _, err := checkURLPolicy(requestURL, w.AllowLocalNetwork); err != nil { + return err + } + + // allowedUrls is required - no fallback to allow all URLs + if len(w.AllowedUrls) == 0 { + return fmt.Errorf("no allowed URLs configured for plugin") + } + + // Check URL patterns + // First try exact matches, then wildcard matches + + // Phase 1: Check for exact matches first + for _, urlPattern := range w.AllowedUrls { + if urlPattern == "*" || (!containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern)) { + return nil + } + } + + // Phase 2: Check wildcard patterns + for _, urlPattern := range w.AllowedUrls { + if containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern) { + return nil + } + } + + return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL) +} + +// containsWildcard checks if a URL pattern contains wildcard characters +func containsWildcard(pattern string) bool { + if pattern == "*" { + return true + } + + // Check for wildcards anywhere in the pattern + for _, char := range pattern { + if char == '*' { + return true + } + } + + return false +} diff --git a/plugins/host_websocket_permissions_test.go b/plugins/host_websocket_permissions_test.go new file mode 100644 index 000000000..e794ca6ad --- /dev/null +++ b/plugins/host_websocket_permissions_test.go @@ -0,0 +1,79 @@ +package plugins + +import ( + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("WebSocket Permissions", func() { + Describe("parseWebSocketPermissions", func() { + It("should parse valid WebSocket permissions", func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to WebSocket API", + AllowLocalNetwork: false, + AllowedUrls: []string{"wss://api.example.com/ws", "wss://cdn.example.com/*"}, + } + + perms, err := parseWebSocketPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms).ToNot(BeNil()) + Expect(perms.AllowLocalNetwork).To(BeFalse()) + Expect(perms.AllowedUrls).To(Equal([]string{"wss://api.example.com/ws", "wss://cdn.example.com/*"})) + }) + + It("should fail if allowedUrls is empty", func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to WebSocket API", + AllowLocalNetwork: false, + AllowedUrls: []string{}, + } + + _, err := parseWebSocketPermissions(permData) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern")) + }) + + It("should handle wildcard patterns", func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to any WebSocket", + AllowLocalNetwork: true, + AllowedUrls: []string{"wss://*"}, + } + + perms, err := parseWebSocketPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms.AllowLocalNetwork).To(BeTrue()) + Expect(perms.AllowedUrls).To(Equal([]string{"wss://*"})) + }) + + Context("URL matching", func() { + var perms *webSocketPermissions + + BeforeEach(func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to external services", + AllowLocalNetwork: true, + AllowedUrls: []string{"wss://api.example.com/*", "ws://localhost:8080"}, + } + var err error + perms, err = parseWebSocketPermissions(permData) + Expect(err).To(BeNil()) + }) + + It("should allow connections to URLs matching patterns", func() { + err := perms.IsConnectionAllowed("wss://api.example.com/v1/stream") + Expect(err).To(BeNil()) + + err = perms.IsConnectionAllowed("ws://localhost:8080") + Expect(err).To(BeNil()) + }) + + It("should deny connections to URLs not matching patterns", func() { + err := perms.IsConnectionAllowed("wss://malicious.com/stream") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not match any allowed URL patterns")) + }) + }) + }) +}) diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go new file mode 100644 index 000000000..b6f4e2094 --- /dev/null +++ b/plugins/host_websocket_test.go @@ -0,0 +1,225 @@ +package plugins + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" + + gorillaws "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/plugins/host/websocket" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("WebSocket Host Service", func() { + var ( + wsService *websocketService + manager *Manager + ctx context.Context + server *httptest.Server + upgrader gorillaws.Upgrader + serverMessages []string + serverMu sync.Mutex + ) + + // WebSocket echo server handler + echoHandler := func(w http.ResponseWriter, r *http.Request) { + // Check headers + if r.Header.Get("X-Test-Header") != "test-value" { + http.Error(w, "Missing or invalid X-Test-Header", http.StatusBadRequest) + return + } + + // Upgrade connection to WebSocket + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // Echo messages back + for { + mt, message, err := conn.ReadMessage() + if err != nil { + break + } + + // Store the received message for verification + if mt == gorillaws.TextMessage { + msg := string(message) + serverMu.Lock() + serverMessages = append(serverMessages, msg) + serverMu.Unlock() + } + + // Echo it back + err = conn.WriteMessage(mt, message) + if err != nil { + break + } + + // If message is "close", close the connection + if mt == gorillaws.TextMessage && string(message) == "close" { + _ = conn.WriteControl( + gorillaws.CloseMessage, + gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "bye"), + time.Now().Add(time.Second), + ) + break + } + } + } + + BeforeEach(func() { + ctx = context.Background() + serverMessages = make([]string, 0) + serverMu = sync.Mutex{} + + // Create a test WebSocket server + //upgrader = gorillaws.Upgrader{} + server = httptest.NewServer(http.HandlerFunc(echoHandler)) + DeferCleanup(server.Close) + + // Create a new manager and websocket service + manager = createManager(nil, nil) + wsService = newWebsocketService(manager) + }) + + Describe("WebSocket operations", func() { + var ( + pluginName string + connectionID string + wsURL string + ) + + BeforeEach(func() { + pluginName = "test-plugin" + connectionID = "test-connection-id" + wsURL = "ws" + strings.TrimPrefix(server.URL, "http") + }) + + It("connects to a WebSocket server", func() { + // Connect to the WebSocket server + req := &websocket.ConnectRequest{ + Url: wsURL, + Headers: map[string]string{ + "X-Test-Header": "test-value", + }, + ConnectionId: connectionID, + } + + resp, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ConnectionId).ToNot(BeEmpty()) + connectionID = resp.ConnectionId + + // Verify that the connection was added to the service + internalID := pluginName + ":" + connectionID + Expect(wsService.hasConnection(internalID)).To(BeTrue()) + }) + + It("sends and receives text messages", func() { + // Connect to the WebSocket server + req := &websocket.ConnectRequest{ + Url: wsURL, + Headers: map[string]string{ + "X-Test-Header": "test-value", + }, + ConnectionId: connectionID, + } + + resp, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).ToNot(HaveOccurred()) + connectionID = resp.ConnectionId + + // Send a text message + textReq := &websocket.SendTextRequest{ + ConnectionId: connectionID, + Message: "hello websocket", + } + + _, err = wsService.sendText(ctx, pluginName, textReq) + Expect(err).ToNot(HaveOccurred()) + + // Wait a bit for the message to be processed + Eventually(func() []string { + serverMu.Lock() + defer serverMu.Unlock() + return serverMessages + }, "1s").Should(ContainElement("hello websocket")) + }) + + It("closes a WebSocket connection", func() { + // Connect to the WebSocket server + req := &websocket.ConnectRequest{ + Url: wsURL, + Headers: map[string]string{ + "X-Test-Header": "test-value", + }, + ConnectionId: connectionID, + } + + resp, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).ToNot(HaveOccurred()) + connectionID = resp.ConnectionId + + initialCount := wsService.connectionCount() + + // Close the connection + closeReq := &websocket.CloseRequest{ + ConnectionId: connectionID, + Code: 1000, // Normal closure + Reason: "test complete", + } + + _, err = wsService.close(ctx, pluginName, closeReq) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the connection was removed + Eventually(func() int { + return wsService.connectionCount() + }, "1s").Should(Equal(initialCount - 1)) + + internalID := pluginName + ":" + connectionID + Expect(wsService.hasConnection(internalID)).To(BeFalse()) + }) + + It("handles connection errors gracefully", func() { + // Try to connect to an invalid URL + req := &websocket.ConnectRequest{ + Url: "ws://invalid-url-that-does-not-exist", + Headers: map[string]string{}, + ConnectionId: connectionID, + } + + _, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when attempting to use non-existent connection", func() { + // Try to send a message to a non-existent connection + textReq := &websocket.SendTextRequest{ + ConnectionId: "non-existent-connection", + Message: "this should fail", + } + + sendResp, err := wsService.sendText(ctx, pluginName, textReq) + Expect(err).ToNot(HaveOccurred()) + Expect(sendResp.Error).To(ContainSubstring("connection not found")) + + // Try to close a non-existent connection + closeReq := &websocket.CloseRequest{ + ConnectionId: "non-existent-connection", + Code: 1000, + Reason: "test complete", + } + + closeResp, err := wsService.close(ctx, pluginName, closeReq) + Expect(err).ToNot(HaveOccurred()) + Expect(closeResp.Error).To(ContainSubstring("connection not found")) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go new file mode 100644 index 000000000..89ff854ae --- /dev/null +++ b/plugins/manager.go @@ -0,0 +1,386 @@ +package plugins + +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative api/api.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/http/http.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/config/config.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/websocket/websocket.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto + +import ( + "context" + "fmt" + "net/http" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/utils/singleton" + "github.com/navidrome/navidrome/utils/slice" + "github.com/tetratelabs/wazero" +) + +const ( + CapabilityMetadataAgent = "MetadataAgent" + CapabilityScrobbler = "Scrobbler" + CapabilitySchedulerCallback = "SchedulerCallback" + CapabilityWebSocketCallback = "WebSocketCallback" + CapabilityLifecycleManagement = "LifecycleManagement" +) + +// pluginCreators maps capability types to their respective creator functions +type pluginConstructor func(wasmPath, pluginID string, m *Manager, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin + +var pluginCreators = map[string]pluginConstructor{ + CapabilityMetadataAgent: newWasmMediaAgent, + CapabilityScrobbler: newWasmScrobblerPlugin, + CapabilitySchedulerCallback: newWasmSchedulerCallback, + CapabilityWebSocketCallback: newWasmWebSocketCallback, +} + +// WasmPlugin is the base interface that all WASM plugins implement +type WasmPlugin interface { + // PluginID returns the unique identifier of the plugin (folder name) + PluginID() string + // Instantiate creates a new instance of the plugin and returns it along with a cleanup function + Instantiate(ctx context.Context) (any, func(), error) +} + +type plugin struct { + ID string + Path string + Capabilities []string + WasmPath string + Manifest *schema.PluginManifest // Loaded manifest + Runtime api.WazeroNewRuntime + ModConfig wazero.ModuleConfig + compilationReady chan struct{} + compilationErr error +} + +func (p *plugin) waitForCompilation() error { + timeout := pluginCompilationTimeout() + select { + case <-p.compilationReady: + case <-time.After(timeout): + err := fmt.Errorf("timed out waiting for plugin %s to compile", p.ID) + log.Error("Timed out waiting for plugin compilation", "name", p.ID, "path", p.WasmPath, "timeout", timeout, "err", err) + return err + } + if p.compilationErr != nil { + log.Error("Failed to compile plugin", "name", p.ID, "path", p.WasmPath, p.compilationErr) + } + return p.compilationErr +} + +type SubsonicRouter http.Handler + +// Manager is a singleton that manages plugins +type Manager struct { + plugins map[string]*plugin // Map of plugin folder name to plugin info + mu sync.RWMutex // Protects plugins map + subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router + schedulerService *schedulerService // Service for handling scheduled tasks + websocketService *websocketService // Service for handling WebSocket connections + lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization + adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter + ds model.DataStore // DataStore for accessing persistent data + metrics metrics.Metrics +} + +// GetManager returns the singleton instance of Manager +func GetManager(ds model.DataStore, metrics metrics.Metrics) *Manager { + return singleton.GetInstance(func() *Manager { + return createManager(ds, metrics) + }) +} + +// createManager creates a new Manager instance. Used in tests +func createManager(ds model.DataStore, metrics metrics.Metrics) *Manager { + m := &Manager{ + plugins: make(map[string]*plugin), + lifecycle: newPluginLifecycleManager(), + ds: ds, + metrics: metrics, + } + + // Create the host services + m.schedulerService = newSchedulerService(m) + m.websocketService = newWebsocketService(m) + + return m +} + +// SetSubsonicRouter sets the SubsonicRouter after Manager initialization +func (m *Manager) SetSubsonicRouter(router SubsonicRouter) { + m.subsonicRouter.Store(&router) +} + +// registerPlugin adds a plugin to the registry with the given parameters +// Used internally by ScanPlugins to register plugins +func (m *Manager) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin { + // Create custom runtime function + customRuntime := m.createRuntime(pluginID, manifest.Permissions) + + // Configure module and determine plugin name + mc := newWazeroModuleConfig() + + // Check if it's a symlink, indicating development mode + isSymlink := false + if fileInfo, err := os.Lstat(pluginDir); err == nil { + isSymlink = fileInfo.Mode()&os.ModeSymlink != 0 + } + + // Store plugin info + p := &plugin{ + ID: pluginID, + Path: pluginDir, + Capabilities: slice.Map(manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { return string(cap) }), + WasmPath: wasmPath, + Manifest: manifest, + Runtime: customRuntime, + ModConfig: mc, + compilationReady: make(chan struct{}), + } + + // Start pre-compilation of WASM module in background + go func() { + precompilePlugin(p) + // Check if this plugin implements InitService and hasn't been initialized yet + m.initializePluginIfNeeded(p) + }() + + // Register the plugin + m.mu.Lock() + defer m.mu.Unlock() + m.plugins[pluginID] = p + + // Register one plugin adapter for each capability + for _, capability := range manifest.Capabilities { + capabilityStr := string(capability) + constructor := pluginCreators[capabilityStr] + if constructor == nil { + // Warn about unknown capabilities, except for LifecycleManagement (it does not have an adapter) + if capability != CapabilityLifecycleManagement { + log.Warn("Unknown plugin capability type", "capability", capability, "plugin", pluginID) + } + continue + } + adapter := constructor(wasmPath, pluginID, m, customRuntime, mc) + if adapter == nil { + log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath) + continue + } + m.adapters[pluginID+"_"+capabilityStr] = adapter + } + + log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink) + return m.plugins[pluginID] +} + +// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement +func (m *Manager) initializePluginIfNeeded(plugin *plugin) { + // Skip if already initialized + if m.lifecycle.isInitialized(plugin) { + return + } + + // Check if the plugin implements LifecycleManagement + for _, capability := range plugin.Manifest.Capabilities { + if capability == CapabilityLifecycleManagement { + m.lifecycle.callOnInit(plugin) + m.lifecycle.markInitialized(plugin) + break + } + } +} + +// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use. +func (m *Manager) ScanPlugins() { + // Clear existing plugins + m.mu.Lock() + m.plugins = make(map[string]*plugin) + m.adapters = make(map[string]WasmPlugin) + m.mu.Unlock() + + // Get plugins directory from config + root := conf.Server.Plugins.Folder + log.Debug("Scanning plugins folder", "root", root) + + // Fail fast if the compilation cache cannot be initialized + _, err := getCompilationCache() + if err != nil { + log.Error("Failed to initialize plugins compilation cache. Disabling plugins", err) + return + } + + // Discover all plugins using the shared discovery function + discoveries := DiscoverPlugins(root) + + var validPluginNames []string + for _, discovery := range discoveries { + if discovery.Error != nil { + // Handle global errors (like directory read failure) + if discovery.ID == "" { + log.Error("Plugin discovery failed", discovery.Error) + return + } + // Handle individual plugin errors + log.Error("Failed to process plugin", "plugin", discovery.ID, discovery.Error) + continue + } + + // Log discovery details + log.Debug("Processing entry", "name", discovery.ID, "isSymlink", discovery.IsSymlink) + if discovery.IsSymlink { + log.Debug("Processing symlinked plugin directory", "name", discovery.ID, "target", discovery.Path) + } + log.Debug("Checking for plugin.wasm", "wasmPath", discovery.WasmPath) + log.Debug("Manifest loaded successfully", "folder", discovery.ID, "name", discovery.Manifest.Name, "capabilities", discovery.Manifest.Capabilities) + + validPluginNames = append(validPluginNames, discovery.ID) + + // Register the plugin + m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest) + } + + log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames) +} + +// PluginNames returns the folder names of all plugins that implement the specified capability +func (m *Manager) PluginNames(capability string) []string { + m.mu.RLock() + defer m.mu.RUnlock() + + var names []string + for name, plugin := range m.plugins { + for _, c := range plugin.Manifest.Capabilities { + if string(c) == capability { + names = append(names, name) + break + } + } + } + return names +} + +func (m *Manager) getPlugin(name string, capability string) (*plugin, WasmPlugin) { + m.mu.RLock() + defer m.mu.RUnlock() + info, infoOk := m.plugins[name] + adapter, adapterOk := m.adapters[name+"_"+capability] + + if !infoOk { + log.Warn("Plugin not found", "name", name) + return nil, nil + } + if !adapterOk { + log.Warn("Plugin adapter not found", "name", name, "capability", capability) + return nil, nil + } + return info, adapter +} + +// LoadPlugin instantiates and returns a plugin by folder name +func (m *Manager) LoadPlugin(name string, capability string) WasmPlugin { + info, adapter := m.getPlugin(name, capability) + if info == nil { + log.Warn("Plugin not found", "name", name, "capability", capability) + return nil + } + + log.Debug("Loading plugin", "name", name, "path", info.Path) + + // Wait for the plugin to be ready before using it. + if err := info.waitForCompilation(); err != nil { + log.Error("Plugin is not ready, cannot be loaded", "plugin", name, "capability", capability, "err", err) + return nil + } + + if adapter == nil { + log.Warn("Plugin adapter not found", "name", name, "capability", capability) + return nil + } + return adapter +} + +// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error. +// This is useful when you need to wait for compilation without loading a specific capability, +// such as during plugin refresh operations or health checks. +func (m *Manager) EnsureCompiled(name string) error { + m.mu.RLock() + plugin, ok := m.plugins[name] + m.mu.RUnlock() + + if !ok { + return fmt.Errorf("plugin not found: %s", name) + } + + return plugin.waitForCompilation() +} + +// LoadAllPlugins instantiates and returns all plugins that implement the specified capability +func (m *Manager) LoadAllPlugins(capability string) []WasmPlugin { + names := m.PluginNames(capability) + if len(names) == 0 { + return nil + } + + var plugins []WasmPlugin + for _, name := range names { + plugin := m.LoadPlugin(name, capability) + if plugin != nil { + plugins = append(plugins, plugin) + } + } + return plugins +} + +// LoadMediaAgent instantiates and returns a media agent plugin by folder name +func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { + plugin := m.LoadPlugin(name, CapabilityMetadataAgent) + if plugin == nil { + return nil, false + } + agent, ok := plugin.(*wasmMediaAgent) + return agent, ok +} + +// LoadAllMediaAgents instantiates and returns all media agent plugins +func (m *Manager) LoadAllMediaAgents() []agents.Interface { + plugins := m.LoadAllPlugins(CapabilityMetadataAgent) + + return slice.Map(plugins, func(p WasmPlugin) agents.Interface { + return p.(agents.Interface) + }) +} + +// LoadScrobbler instantiates and returns a scrobbler plugin by folder name +func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { + plugin := m.LoadPlugin(name, CapabilityScrobbler) + if plugin == nil { + return nil, false + } + s, ok := plugin.(scrobbler.Scrobbler) + return s, ok +} + +// LoadAllScrobblers instantiates and returns all scrobbler plugins +func (m *Manager) LoadAllScrobblers() []scrobbler.Scrobbler { + plugins := m.LoadAllPlugins(CapabilityScrobbler) + + return slice.Map(plugins, func(p WasmPlugin) scrobbler.Scrobbler { + return p.(scrobbler.Scrobbler) + }) +} diff --git a/plugins/manager_test.go b/plugins/manager_test.go new file mode 100644 index 000000000..55a3b8f72 --- /dev/null +++ b/plugins/manager_test.go @@ -0,0 +1,257 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Plugin Manager", func() { + var mgr *Manager + var ctx context.Context + + BeforeEach(func() { + // We change the plugins folder to random location to avoid conflicts with other tests, + // but, as this is an integration test, we can't use configtest.SetupConfig() as it causes + // data races. + originalPluginsFolder := conf.Server.Plugins.Folder + DeferCleanup(func() { + conf.Server.Plugins.Folder = originalPluginsFolder + }) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = testDataDir + + ctx = GinkgoT().Context() + mgr = createManager(nil, nil) + mgr.ScanPlugins() + }) + + It("should scan and discover plugins from the testdata folder", func() { + Expect(mgr).NotTo(BeNil()) + + mediaAgentNames := mgr.PluginNames("MetadataAgent") + Expect(mediaAgentNames).To(HaveLen(4)) + Expect(mediaAgentNames).To(ContainElement("fake_artist_agent")) + Expect(mediaAgentNames).To(ContainElement("fake_album_agent")) + Expect(mediaAgentNames).To(ContainElement("multi_plugin")) + Expect(mediaAgentNames).To(ContainElement("unauthorized_plugin")) + + scrobblerNames := mgr.PluginNames("Scrobbler") + Expect(scrobblerNames).To(ContainElement("fake_scrobbler")) + + initServiceNames := mgr.PluginNames("LifecycleManagement") + Expect(initServiceNames).To(ContainElement("multi_plugin")) + Expect(initServiceNames).To(ContainElement("fake_init_service")) + }) + + It("should load a MetadataAgent plugin and invoke artist-related methods", func() { + plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent) + Expect(plugin).NotTo(BeNil()) + + agent, ok := plugin.(agents.Interface) + Expect(ok).To(BeTrue(), "plugin should implement agents.Interface") + Expect(agent.AgentName()).To(Equal("fake_artist_agent")) + + mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever) + Expect(ok).To(BeTrue()) + mbid, err := mbidRetriever.GetArtistMBID(ctx, "123", "The Beatles") + Expect(err).NotTo(HaveOccurred()) + Expect(mbid).To(Equal("1234567890")) + }) + + It("should load all MetadataAgent plugins", func() { + agents := mgr.LoadAllMediaAgents() + Expect(agents).To(HaveLen(4)) + var names []string + for _, a := range agents { + names = append(names, a.AgentName()) + } + Expect(names).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin")) + }) + + Describe("ScanPlugins", func() { + var tempPluginsDir string + var m *Manager + + BeforeEach(func() { + tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*") + DeferCleanup(func() { + _ = os.RemoveAll(tempPluginsDir) + }) + + conf.Server.Plugins.Folder = tempPluginsDir + m = createManager(nil, nil) + }) + + // Helper to create a complete valid plugin for manager testing + createValidPlugin := func(folderName, manifestName string) { + pluginDir := filepath.Join(tempPluginsDir, folderName) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "` + manifestName + `", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/` + manifestName + `", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + It("should register and compile discovered plugins", func() { + createValidPlugin("test-plugin", "test-plugin") + + m.ScanPlugins() + + // Focus on manager behavior: registration and compilation + Expect(m.plugins).To(HaveLen(1)) + Expect(m.plugins).To(HaveKey("test-plugin")) + + plugin := m.plugins["test-plugin"] + Expect(plugin.ID).To(Equal("test-plugin")) + Expect(plugin.Manifest.Name).To(Equal("test-plugin")) + + // Verify plugin can be loaded (compilation successful) + loadedPlugin := m.LoadPlugin("test-plugin", CapabilityMetadataAgent) + Expect(loadedPlugin).NotTo(BeNil()) + }) + + It("should handle multiple plugins with different IDs but same manifest names", func() { + // This tests manager-specific behavior: how it handles ID conflicts + createValidPlugin("lastfm-official", "lastfm") + createValidPlugin("lastfm-custom", "lastfm") + + m.ScanPlugins() + + // Both should be registered with their folder names as IDs + Expect(m.plugins).To(HaveLen(2)) + Expect(m.plugins).To(HaveKey("lastfm-official")) + Expect(m.plugins).To(HaveKey("lastfm-custom")) + + // Both should be loadable independently + official := m.LoadPlugin("lastfm-official", CapabilityMetadataAgent) + custom := m.LoadPlugin("lastfm-custom", CapabilityMetadataAgent) + Expect(official).NotTo(BeNil()) + Expect(custom).NotTo(BeNil()) + Expect(official.PluginID()).To(Equal("lastfm-official")) + Expect(custom.PluginID()).To(Equal("lastfm-custom")) + }) + }) + + Describe("LoadPlugin", func() { + It("should load a MetadataAgent plugin and invoke artist-related methods", func() { + plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent) + Expect(plugin).NotTo(BeNil()) + + agent, ok := plugin.(agents.Interface) + Expect(ok).To(BeTrue(), "plugin should implement agents.Interface") + Expect(agent.AgentName()).To(Equal("fake_artist_agent")) + + mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever) + Expect(ok).To(BeTrue()) + mbid, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist") + Expect(err).NotTo(HaveOccurred()) + Expect(mbid).To(Equal("1234567890")) + }) + }) + + Describe("EnsureCompiled", func() { + It("should successfully wait for plugin compilation", func() { + err := mgr.EnsureCompiled("fake_artist_agent") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return error for non-existent plugin", func() { + err := mgr.EnsureCompiled("non-existent-plugin") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("plugin not found: non-existent-plugin")) + }) + + It("should wait for compilation to complete for all valid plugins", func() { + pluginNames := []string{"fake_artist_agent", "fake_album_agent", "multi_plugin", "fake_scrobbler"} + + for _, name := range pluginNames { + err := mgr.EnsureCompiled(name) + Expect(err).NotTo(HaveOccurred(), "plugin %s should compile successfully", name) + } + }) + }) + + Describe("Invoke Methods", func() { + It("should load all MetadataAgent plugins and invoke methods", func() { + mediaAgentNames := mgr.PluginNames("MetadataAgent") + Expect(mediaAgentNames).NotTo(BeEmpty()) + + plugins := mgr.LoadAllPlugins("MetadataAgent") + Expect(plugins).To(HaveLen(len(mediaAgentNames))) + + var fakeAlbumPlugin agents.Interface + for _, p := range plugins { + if agent, ok := p.(agents.Interface); ok { + if agent.AgentName() == "fake_album_agent" { + fakeAlbumPlugin = agent + break + } + } + } + + Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded") + + // Test GetAlbumInfo method - need to cast to the specific interface + albumRetriever, ok := fakeAlbumPlugin.(agents.AlbumInfoRetriever) + Expect(ok).To(BeTrue(), "fake_album_agent should implement AlbumInfoRetriever") + + info, err := albumRetriever.GetAlbumInfo(ctx, "Test Album", "Test Artist", "123") + Expect(err).NotTo(HaveOccurred()) + Expect(info).NotTo(BeNil()) + Expect(info.Name).To(Equal("Test Album")) + }) + }) + + Describe("Permission Enforcement Integration", func() { + It("should fail when plugin tries to access unauthorized services", func() { + // This plugin tries to access config service but has no permissions + plugin := mgr.LoadPlugin("unauthorized_plugin", CapabilityMetadataAgent) + Expect(plugin).NotTo(BeNil()) + + agent, ok := plugin.(agents.Interface) + Expect(ok).To(BeTrue()) + + // This should fail because the plugin tries to access unauthorized config service + // The exact behavior depends on the plugin implementation, but it should either: + // 1. Fail during instantiation, or + // 2. Return an error when trying to call config methods + + // Try to use one of the available methods - let's test with GetArtistMBID + mbidRetriever, isMBIDRetriever := agent.(agents.ArtistMBIDRetriever) + if isMBIDRetriever { + _, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist") + if err == nil { + // If no error, the plugin should still be working + // but any config access should fail silently or return default values + Expect(agent.AgentName()).To(Equal("unauthorized_plugin")) + } else { + // If there's an error, it should be related to missing permissions + Expect(err.Error()).To(ContainSubstring("")) + } + } else { + // If the plugin doesn't implement the interface, that's also acceptable + Expect(agent.AgentName()).To(Equal("unauthorized_plugin")) + } + }) + }) +}) diff --git a/plugins/manifest.go b/plugins/manifest.go new file mode 100644 index 000000000..b56187bcc --- /dev/null +++ b/plugins/manifest.go @@ -0,0 +1,30 @@ +package plugins + +//go:generate go tool go-jsonschema --schema-root-type navidrome://plugins/manifest=PluginManifest -p schema --output schema/manifest_gen.go schema/manifest.schema.json + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// LoadManifest loads and parses the manifest.json file from the given plugin directory. +// Returns the generated schema.PluginManifest type with full validation and type safety. +func LoadManifest(pluginDir string) (*schema.PluginManifest, error) { + manifestPath := filepath.Join(pluginDir, "manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest file: %w", err) + } + + var manifest schema.PluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("invalid manifest: %w", err) + } + + return &manifest, nil +} diff --git a/plugins/manifest_permissions_test.go b/plugins/manifest_permissions_test.go new file mode 100644 index 000000000..da221eb56 --- /dev/null +++ b/plugins/manifest_permissions_test.go @@ -0,0 +1,525 @@ +package plugins + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Helper function to create test plugins with typed permissions +func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPermissions) string { + pluginDir := filepath.Join(tempDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Use the generated PluginManifest type directly - it handles JSON marshaling automatically + manifest := schema.PluginManifest{ + Name: name, + Author: "Test Author", + Version: "1.0.0", + Description: "Test plugin for permissions", + Website: "https://test.navidrome.org/" + name, + Capabilities: []schema.PluginManifestCapabilitiesElem{ + schema.PluginManifestCapabilitiesElemMetadataAgent, + }, + Permissions: permissions, + } + + // Marshal the typed manifest directly - gets all validation for free + manifestData, err := json.Marshal(manifest) + Expect(err).NotTo(HaveOccurred()) + + manifestPath := filepath.Join(pluginDir, "manifest.json") + Expect(os.WriteFile(manifestPath, manifestData, 0600)).To(Succeed()) + + // Create fake WASM file (since plugin discovery checks for it) + wasmPath := filepath.Join(pluginDir, "plugin.wasm") + Expect(os.WriteFile(wasmPath, []byte("fake wasm content"), 0600)).To(Succeed()) + + return pluginDir +} + +var _ = Describe("Plugin Permissions", func() { + var ( + mgr *Manager + tempDir string + ctx context.Context + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + mgr = createManager(nil, nil) + tempDir = GinkgoT().TempDir() + }) + + Describe("Permission Enforcement in createRuntime", func() { + It("should only load services specified in permissions", func() { + // Test with limited permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + + runtimeFunc := mgr.createRuntime("test-plugin", permissions) + + // Create runtime to test service availability + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // The runtime was created successfully with the specified permissions + Expect(runtime).NotTo(BeNil()) + + // Note: The actual verification of which specific host functions are available + // would require introspecting the WASM runtime, which is complex. + // The key test is that the runtime creation succeeds with valid permissions. + }) + + It("should create runtime with empty permissions", func() { + permissions := schema.PluginManifestPermissions{} + + runtimeFunc := mgr.createRuntime("empty-permissions-plugin", permissions) + + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Should succeed but with no host services available + Expect(runtime).NotTo(BeNil()) + }) + + It("should handle all available permissions", func() { + // Test with all possible permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic tasks", + }, + Websocket: &schema.PluginManifestPermissionsWebsocket{ + Reason: "To handle real-time communication", + AllowedUrls: []string{"wss://api.example.com"}, + AllowLocalNetwork: false, + }, + Cache: &schema.PluginManifestPermissionsCache{ + Reason: "To cache data and reduce API calls", + }, + Artwork: &schema.PluginManifestPermissionsArtwork{ + Reason: "To generate artwork URLs", + }, + } + + runtimeFunc := mgr.createRuntime("full-permissions-plugin", permissions) + + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + Expect(runtime).NotTo(BeNil()) + }) + }) + + Describe("Plugin Discovery with Permissions", func() { + BeforeEach(func() { + conf.Server.Plugins.Folder = tempDir + }) + + It("should discover plugin with valid permissions manifest", func() { + // Create plugin with http permission using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch metadata from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + }, + } + createTestPlugin(tempDir, "valid-plugin", permissions) + + // Scan for plugins + mgr.ScanPlugins() + + // Verify plugin was discovered (even without valid WASM) + pluginNames := mgr.PluginNames("MetadataAgent") + Expect(pluginNames).To(ContainElement("valid-plugin")) + }) + + It("should discover plugin with no permissions", func() { + // Create plugin with empty permissions using typed structs + permissions := schema.PluginManifestPermissions{} + createTestPlugin(tempDir, "no-perms-plugin", permissions) + + mgr.ScanPlugins() + + pluginNames := mgr.PluginNames("MetadataAgent") + Expect(pluginNames).To(ContainElement("no-perms-plugin")) + }) + + It("should discover plugin with multiple permissions", func() { + // Create plugin with multiple permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch metadata from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read plugin configuration settings", + }, + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic data updates", + }, + } + createTestPlugin(tempDir, "multi-perms-plugin", permissions) + + mgr.ScanPlugins() + + pluginNames := mgr.PluginNames("MetadataAgent") + Expect(pluginNames).To(ContainElement("multi-perms-plugin")) + }) + }) + + Describe("Existing Plugin Permissions", func() { + BeforeEach(func() { + // Use the testdata directory with updated plugins + conf.Server.Plugins.Folder = testDataDir + mgr.ScanPlugins() + }) + + It("should discover fake_scrobbler with empty permissions", func() { + scrobblerNames := mgr.PluginNames(CapabilityScrobbler) + Expect(scrobblerNames).To(ContainElement("fake_scrobbler")) + }) + + It("should discover multi_plugin with scheduler permissions", func() { + agentNames := mgr.PluginNames(CapabilityMetadataAgent) + Expect(agentNames).To(ContainElement("multi_plugin")) + }) + + It("should discover all test plugins successfully", func() { + // All test plugins should be discovered with their updated permissions + testPlugins := []struct { + name string + capability string + }{ + {"fake_album_agent", CapabilityMetadataAgent}, + {"fake_artist_agent", CapabilityMetadataAgent}, + {"fake_scrobbler", CapabilityScrobbler}, + {"multi_plugin", CapabilityMetadataAgent}, + {"fake_init_service", CapabilityLifecycleManagement}, + } + + for _, testPlugin := range testPlugins { + pluginNames := mgr.PluginNames(testPlugin.capability) + Expect(pluginNames).To(ContainElement(testPlugin.name), "Plugin %s should be discovered", testPlugin.name) + } + }) + }) + + Describe("Permission Validation", func() { + It("should enforce permissions are required in manifest", func() { + // Create a manifest JSON string without the permissions field + manifestContent := `{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent"] + }` + + manifestPath := filepath.Join(tempDir, "manifest.json") + err := os.WriteFile(manifestPath, []byte(manifestContent), 0600) + Expect(err).NotTo(HaveOccurred()) + + _, err = LoadManifest(tempDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field permissions in PluginManifest: required")) + }) + + It("should allow unknown permission keys", func() { + // Create manifest with both known and unknown permission types + pluginDir := filepath.Join(tempDir, "unknown-perms") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + manifestContent := `{ + "name": "unknown-perms", + "author": "Test Author", + "version": "1.0.0", + "description": "Manifest with unknown permissions", + "website": "https://test.navidrome.org/unknown-perms", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch data from external APIs", + "allowedUrls": { + "*": ["*"] + } + }, + "unknown": { + "customField": "customValue" + } + } + }` + + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + // Test manifest loading directly - should succeed even with unknown permissions + loadedManifest, err := LoadManifest(pluginDir) + Expect(err).NotTo(HaveOccurred()) + Expect(loadedManifest).NotTo(BeNil()) + // With typed permissions, we check the specific fields + Expect(loadedManifest.Permissions.Http).NotTo(BeNil()) + Expect(loadedManifest.Permissions.Http.Reason).To(Equal("To fetch data from external APIs")) + // The key point is that the manifest loads successfully despite unknown permissions + // The actual handling of AdditionalProperties depends on the JSON schema implementation + }) + }) + + Describe("Runtime Pool with Permissions", func() { + It("should create separate runtimes for different permission sets", func() { + // Create two different permission sets using typed structs + permissions1 := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + } + permissions2 := schema.PluginManifestPermissions{ + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + + runtimeFunc1 := mgr.createRuntime("plugin1", permissions1) + runtimeFunc2 := mgr.createRuntime("plugin2", permissions2) + + runtime1, err1 := runtimeFunc1(ctx) + Expect(err1).NotTo(HaveOccurred()) + defer runtime1.Close(ctx) + + runtime2, err2 := runtimeFunc2(ctx) + Expect(err2).NotTo(HaveOccurred()) + defer runtime2.Close(ctx) + + // Should be different runtime instances + Expect(runtime1).NotTo(BeIdenticalTo(runtime2)) + }) + }) + + Describe("Permission System Integration", func() { + It("should successfully validate manifests with permissions", func() { + // Create a valid manifest with permissions + pluginDir := filepath.Join(tempDir, "valid-manifest") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + manifestContent := `{ + "name": "valid-manifest", + "author": "Test Author", + "version": "1.0.0", + "description": "Valid manifest with permissions", + "website": "https://test.navidrome.org/valid-manifest", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch metadata from external APIs", + "allowedUrls": { + "*": ["*"] + } + }, + "config": { + "reason": "To read plugin configuration settings" + } + } + }` + + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + // Load the manifest - should succeed + manifest, err := LoadManifest(pluginDir) + Expect(err).NotTo(HaveOccurred()) + Expect(manifest).NotTo(BeNil()) + // With typed permissions, check the specific permission fields + Expect(manifest.Permissions.Http).NotTo(BeNil()) + Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata from external APIs")) + Expect(manifest.Permissions.Config).NotTo(BeNil()) + Expect(manifest.Permissions.Config.Reason).To(Equal("To read plugin configuration settings")) + }) + + It("should track which services are requested per plugin", func() { + // Test that different plugins can have different permission sets + permissions1 := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + permissions2 := schema.PluginManifestPermissions{ + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic tasks", + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration for scheduler", + }, + } + permissions3 := schema.PluginManifestPermissions{} // Empty permissions + + createTestPlugin(tempDir, "plugin-with-http", permissions1) + createTestPlugin(tempDir, "plugin-with-scheduler", permissions2) + createTestPlugin(tempDir, "plugin-with-none", permissions3) + + conf.Server.Plugins.Folder = tempDir + mgr.ScanPlugins() + + // All should be discovered + pluginNames := mgr.PluginNames(CapabilityMetadataAgent) + Expect(pluginNames).To(ContainElement("plugin-with-http")) + Expect(pluginNames).To(ContainElement("plugin-with-scheduler")) + Expect(pluginNames).To(ContainElement("plugin-with-none")) + }) + }) + + Describe("Runtime Service Access Control", func() { + It("should successfully create runtime with permitted services", func() { + // Create runtime with HTTP permission using typed struct + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + } + + runtimeFunc := mgr.createRuntime("http-only-plugin", permissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should be created successfully - host functions are loaded during runtime creation + Expect(runtime).NotTo(BeNil()) + }) + + It("should successfully create runtime with multiple permitted services", func() { + // Create runtime with multiple permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic tasks", + }, + } + + runtimeFunc := mgr.createRuntime("multi-service-plugin", permissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should be created successfully + Expect(runtime).NotTo(BeNil()) + }) + + It("should create runtime with no services when no permissions granted", func() { + // Create runtime with empty permissions using typed struct + emptyPermissions := schema.PluginManifestPermissions{} + + runtimeFunc := mgr.createRuntime("no-service-plugin", emptyPermissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should still be created, but with no host services + Expect(runtime).NotTo(BeNil()) + }) + + It("should demonstrate secure-by-default behavior", func() { + // Test that default (empty permissions) provides no services + defaultPermissions := schema.PluginManifestPermissions{} + runtimeFunc := mgr.createRuntime("default-plugin", defaultPermissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should be created but with no host services + Expect(runtime).NotTo(BeNil()) + }) + + It("should test permission enforcement by simulating unauthorized service access", func() { + // This test demonstrates that plugins would fail at runtime when trying to call + // host functions they don't have permission for, since those functions are simply + // not loaded into the WASM runtime environment. + + // Create two different runtimes with different permissions using typed structs + httpOnlyPermissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + } + configOnlyPermissions := schema.PluginManifestPermissions{ + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + + httpRuntime, err := mgr.createRuntime("http-only", httpOnlyPermissions)(ctx) + Expect(err).NotTo(HaveOccurred()) + defer httpRuntime.Close(ctx) + + configRuntime, err := mgr.createRuntime("config-only", configOnlyPermissions)(ctx) + Expect(err).NotTo(HaveOccurred()) + defer configRuntime.Close(ctx) + + // Both runtimes should be created successfully, but they will have different + // sets of host functions available. A plugin trying to call unauthorized + // functions would get "function not found" errors during instantiation or execution. + Expect(httpRuntime).NotTo(BeNil()) + Expect(configRuntime).NotTo(BeNil()) + }) + }) +}) diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go new file mode 100644 index 000000000..2ec3edd19 --- /dev/null +++ b/plugins/manifest_test.go @@ -0,0 +1,144 @@ +package plugins + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Plugin Manifest", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + It("should load and parse a valid manifest", func() { + manifestPath := filepath.Join(tempDir, "manifest.json") + manifestContent := []byte(`{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent", "Scrobbler"], + "permissions": { + "http": { + "reason": "To fetch metadata", + "allowedUrls": { + "https://api.example.com/*": ["GET"] + } + } + } + }`) + + err := os.WriteFile(manifestPath, manifestContent, 0600) + Expect(err).NotTo(HaveOccurred()) + + manifest, err := LoadManifest(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(manifest).NotTo(BeNil()) + Expect(manifest.Name).To(Equal("test-plugin")) + Expect(manifest.Author).To(Equal("Test Author")) + Expect(manifest.Version).To(Equal("1.0.0")) + Expect(manifest.Description).To(Equal("A test plugin")) + Expect(manifest.Capabilities).To(HaveLen(2)) + Expect(manifest.Capabilities[0]).To(Equal(schema.PluginManifestCapabilitiesElemMetadataAgent)) + Expect(manifest.Capabilities[1]).To(Equal(schema.PluginManifestCapabilitiesElemScrobbler)) + Expect(manifest.Permissions.Http).NotTo(BeNil()) + Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata")) + }) + + It("should fail with proper error for non-existent manifest", func() { + _, err := LoadManifest(filepath.Join(tempDir, "non-existent")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read manifest file")) + }) + + It("should fail with JSON parse error for invalid JSON", func() { + // Create invalid JSON + invalidJSON := `{ + "name": "test-plugin", + "author": "Test Author" + "version": "1.0.0" + "description": "A test plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "invalid-json") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidJSON), 0600)).To(Succeed()) + + // Test validation fails + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid manifest")) + }) + + It("should validate manifest against schema with detailed error for missing required field", func() { + // Create manifest missing required name field + manifestContent := `{ + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "test-plugin") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field name in PluginManifest: required")) + }) + + It("should validate manifest with wrong capability type", func() { + // Create manifest with invalid capability + manifestContent := `{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["UnsupportedService"], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "test-plugin") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid value")) + Expect(err.Error()).To(ContainSubstring("UnsupportedService")) + }) + + It("should validate manifest with empty capabilities array", func() { + // Create manifest with empty capabilities array + manifestContent := `{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": [], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "test-plugin") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field capabilities length: must be >= 1")) + }) +}) diff --git a/plugins/package.go b/plugins/package.go new file mode 100644 index 000000000..5273b0431 --- /dev/null +++ b/plugins/package.go @@ -0,0 +1,177 @@ +package plugins + +import ( + "archive/zip" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// PluginPackage represents a Navidrome Plugin Package (.ndp file) +type PluginPackage struct { + ManifestJSON []byte + Manifest *schema.PluginManifest + WasmBytes []byte + Docs map[string][]byte +} + +// ExtractPackage extracts a .ndp file to the target directory +func ExtractPackage(ndpPath, targetDir string) error { + r, err := zip.OpenReader(ndpPath) + if err != nil { + return fmt.Errorf("error opening .ndp file: %w", err) + } + defer r.Close() + + // Create target directory if it doesn't exist + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("error creating plugin directory: %w", err) + } + + // Define a reasonable size limit for plugin files to prevent decompression bombs + const maxFileSize = 10 * 1024 * 1024 // 10 MB limit + + // Extract all files from the zip + for _, f := range r.File { + // Skip directories (they will be created as needed) + if f.FileInfo().IsDir() { + continue + } + + // Create the file path for extraction + // Validate the file name to prevent directory traversal or absolute paths + if strings.Contains(f.Name, "..") || filepath.IsAbs(f.Name) { + return fmt.Errorf("illegal file path in plugin package: %s", f.Name) + } + + // Create the file path for extraction + targetPath := filepath.Join(targetDir, f.Name) // #nosec G305 + + // Clean the path to prevent directory traversal. + cleanedPath := filepath.Clean(targetPath) + // Ensure the cleaned path is still within the target directory. + // We resolve both paths to absolute paths to be sure. + absTargetDir, err := filepath.Abs(targetDir) + if err != nil { + return fmt.Errorf("failed to resolve target directory path: %w", err) + } + absTargetPath, err := filepath.Abs(cleanedPath) + if err != nil { + return fmt.Errorf("failed to resolve extracted file path: %w", err) + } + if !strings.HasPrefix(absTargetPath, absTargetDir+string(os.PathSeparator)) && absTargetPath != absTargetDir { + return fmt.Errorf("illegal file path in plugin package: %s", f.Name) + } + + // Open the file inside the zip + rc, err := f.Open() + if err != nil { + return fmt.Errorf("error opening file in plugin package: %w", err) + } + + // Create parent directories if they don't exist + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + rc.Close() + return fmt.Errorf("error creating directory structure: %w", err) + } + + // Create the file + outFile, err := os.Create(targetPath) + if err != nil { + rc.Close() + return fmt.Errorf("error creating extracted file: %w", err) + } + + // Copy the file contents with size limit + if _, err := io.CopyN(outFile, rc, maxFileSize); err != nil && !errors.Is(err, io.EOF) { + outFile.Close() + rc.Close() + if errors.Is(err, io.ErrUnexpectedEOF) { // File size exceeds limit + return fmt.Errorf("error extracting file: size exceeds limit (%d bytes) for %s", maxFileSize, f.Name) + } + return fmt.Errorf("error writing extracted file: %w", err) + } + + outFile.Close() + rc.Close() + + // Set appropriate file permissions (0600 - readable only by owner) + if err := os.Chmod(targetPath, 0600); err != nil { + return fmt.Errorf("error setting permissions on extracted file: %w", err) + } + } + + return nil +} + +// LoadPackage loads and validates an .ndp file without extracting it +func LoadPackage(ndpPath string) (*PluginPackage, error) { + r, err := zip.OpenReader(ndpPath) + if err != nil { + return nil, fmt.Errorf("error opening .ndp file: %w", err) + } + defer r.Close() + + pkg := &PluginPackage{ + Docs: make(map[string][]byte), + } + + // Required files + var hasManifest, hasWasm bool + + // Read all files in the zip + for _, f := range r.File { + // Skip directories + if f.FileInfo().IsDir() { + continue + } + + // Get file content + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("error opening file in plugin package: %w", err) + } + + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("error reading file in plugin package: %w", err) + } + + // Process based on file name + switch strings.ToLower(f.Name) { + case "manifest.json": + pkg.ManifestJSON = content + hasManifest = true + case "plugin.wasm": + pkg.WasmBytes = content + hasWasm = true + default: + // Store other files as documentation + pkg.Docs[f.Name] = content + } + } + + // Ensure required files exist + if !hasManifest { + return nil, fmt.Errorf("plugin package missing required manifest.json") + } + if !hasWasm { + return nil, fmt.Errorf("plugin package missing required plugin.wasm") + } + + // Parse and validate the manifest + var manifest schema.PluginManifest + if err := json.Unmarshal(pkg.ManifestJSON, &manifest); err != nil { + return nil, fmt.Errorf("invalid manifest: %w", err) + } + + pkg.Manifest = &manifest + return pkg, nil +} diff --git a/plugins/package_test.go b/plugins/package_test.go new file mode 100644 index 000000000..8ff4b354a --- /dev/null +++ b/plugins/package_test.go @@ -0,0 +1,116 @@ +package plugins + +import ( + "archive/zip" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Plugin Package", func() { + var tempDir string + var ndpPath string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + + // Create a test .ndp file + ndpPath = filepath.Join(tempDir, "test-plugin.ndp") + + // Create the required plugin files + manifestContent := []byte(`{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} + }`) + + wasmContent := []byte("dummy wasm content") + readmeContent := []byte("# Test Plugin\nThis is a test plugin") + + // Create the zip file + zipFile, err := os.Create(ndpPath) + Expect(err).NotTo(HaveOccurred()) + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add manifest.json + manifestWriter, err := zipWriter.Create("manifest.json") + Expect(err).NotTo(HaveOccurred()) + _, err = manifestWriter.Write(manifestContent) + Expect(err).NotTo(HaveOccurred()) + + // Add plugin.wasm + wasmWriter, err := zipWriter.Create("plugin.wasm") + Expect(err).NotTo(HaveOccurred()) + _, err = wasmWriter.Write(wasmContent) + Expect(err).NotTo(HaveOccurred()) + + // Add README.md + readmeWriter, err := zipWriter.Create("README.md") + Expect(err).NotTo(HaveOccurred()) + _, err = readmeWriter.Write(readmeContent) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should load and validate a plugin package", func() { + pkg, err := LoadPackage(ndpPath) + Expect(err).NotTo(HaveOccurred()) + Expect(pkg).NotTo(BeNil()) + + // Check manifest was parsed + Expect(pkg.Manifest).NotTo(BeNil()) + Expect(pkg.Manifest.Name).To(Equal("test-plugin")) + Expect(pkg.Manifest.Author).To(Equal("Test Author")) + Expect(pkg.Manifest.Version).To(Equal("1.0.0")) + Expect(pkg.Manifest.Description).To(Equal("A test plugin")) + Expect(pkg.Manifest.Capabilities).To(HaveLen(1)) + Expect(pkg.Manifest.Capabilities[0]).To(Equal(schema.PluginManifestCapabilitiesElemMetadataAgent)) + + // Check WASM file was loaded + Expect(pkg.WasmBytes).NotTo(BeEmpty()) + + // Check docs were loaded + Expect(pkg.Docs).To(HaveKey("README.md")) + }) + + It("should extract a plugin package to a directory", func() { + targetDir := filepath.Join(tempDir, "extracted") + + err := ExtractPackage(ndpPath, targetDir) + Expect(err).NotTo(HaveOccurred()) + + // Check files were extracted + Expect(filepath.Join(targetDir, "manifest.json")).To(BeARegularFile()) + Expect(filepath.Join(targetDir, "plugin.wasm")).To(BeARegularFile()) + Expect(filepath.Join(targetDir, "README.md")).To(BeARegularFile()) + }) + + It("should fail to load an invalid package", func() { + // Create an invalid package (missing required files) + invalidPath := filepath.Join(tempDir, "invalid.ndp") + zipFile, err := os.Create(invalidPath) + Expect(err).NotTo(HaveOccurred()) + + zipWriter := zip.NewWriter(zipFile) + // Only add a README, missing manifest and wasm + readmeWriter, err := zipWriter.Create("README.md") + Expect(err).NotTo(HaveOccurred()) + _, err = readmeWriter.Write([]byte("Invalid package")) + Expect(err).NotTo(HaveOccurred()) + zipWriter.Close() + zipFile.Close() + + // Test loading fails + _, err = LoadPackage(invalidPath) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/plugins/plugin_lifecycle_manager.go b/plugins/plugin_lifecycle_manager.go new file mode 100644 index 000000000..7df0921d8 --- /dev/null +++ b/plugins/plugin_lifecycle_manager.go @@ -0,0 +1,86 @@ +package plugins + +import ( + "context" + "maps" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" +) + +// pluginLifecycleManager tracks which plugins have been initialized and manages their lifecycle +type pluginLifecycleManager struct { + plugins sync.Map // string -> bool + config map[string]map[string]string +} + +// newPluginLifecycleManager creates a new plugin lifecycle manager +func newPluginLifecycleManager() *pluginLifecycleManager { + config := maps.Clone(conf.Server.PluginConfig) + return &pluginLifecycleManager{ + config: config, + } +} + +// isInitialized checks if a plugin has been initialized +func (m *pluginLifecycleManager) isInitialized(plugin *plugin) bool { + key := plugin.ID + consts.Zwsp + plugin.Manifest.Version + value, exists := m.plugins.Load(key) + return exists && value.(bool) +} + +// markInitialized marks a plugin as initialized +func (m *pluginLifecycleManager) markInitialized(plugin *plugin) { + key := plugin.ID + consts.Zwsp + plugin.Manifest.Version + m.plugins.Store(key, true) +} + +// callOnInit calls the OnInit method on a plugin that implements LifecycleManagement +func (m *pluginLifecycleManager) callOnInit(plugin *plugin) { + ctx := context.Background() + log.Debug("Initializing plugin", "name", plugin.ID) + start := time.Now() + + // Create LifecycleManagement plugin instance + loader, err := api.NewLifecycleManagementPlugin(ctx, api.WazeroRuntime(plugin.Runtime), api.WazeroModuleConfig(plugin.ModConfig)) + if loader == nil || err != nil { + log.Error("Error creating LifecycleManagement plugin", "plugin", plugin.ID, err) + return + } + + initPlugin, err := loader.Load(ctx, plugin.WasmPath) + if err != nil { + log.Error("Error loading LifecycleManagement plugin", "plugin", plugin.ID, "path", plugin.WasmPath, err) + return + } + defer initPlugin.Close(ctx) + + // Prepare the request with plugin-specific configuration + req := &api.InitRequest{} + + // Add plugin configuration if available + if m.config != nil { + if pluginConfig, ok := m.config[plugin.ID]; ok && len(pluginConfig) > 0 { + req.Config = maps.Clone(pluginConfig) + log.Debug("Passing configuration to plugin", "plugin", plugin.ID, "configKeys", len(pluginConfig)) + } + } + + // Call OnInit + resp, err := initPlugin.OnInit(ctx, req) + if err != nil { + log.Error("Error initializing plugin", "plugin", plugin.ID, "elapsed", time.Since(start), err) + return + } + + if resp.Error != "" { + log.Error("Plugin reported error during initialization", "plugin", plugin.ID, "error", resp.Error) + return + } + + log.Debug("Plugin initialized successfully", "plugin", plugin.ID, "elapsed", time.Since(start)) +} diff --git a/plugins/plugin_lifecycle_manager_test.go b/plugins/plugin_lifecycle_manager_test.go new file mode 100644 index 000000000..c0621b2a7 --- /dev/null +++ b/plugins/plugin_lifecycle_manager_test.go @@ -0,0 +1,144 @@ +package plugins + +import ( + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Helper function to check if a plugin implements LifecycleManagement +func hasInitService(info *plugin) bool { + for _, c := range info.Capabilities { + if c == CapabilityLifecycleManagement { + return true + } + } + return false +} + +var _ = Describe("LifecycleManagement", func() { + Describe("Plugin Lifecycle Manager", func() { + var lifecycleManager *pluginLifecycleManager + + BeforeEach(func() { + lifecycleManager = newPluginLifecycleManager() + }) + + It("should track initialization state of plugins", func() { + // Create test plugins + plugin1 := &plugin{ + ID: "test-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + plugin2 := &plugin{ + ID: "another-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "0.5.0", + }, + } + + // Initially, no plugins should be initialized + Expect(lifecycleManager.isInitialized(plugin1)).To(BeFalse()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse()) + + // Mark first plugin as initialized + lifecycleManager.markInitialized(plugin1) + + // Check state + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse()) + + // Mark second plugin as initialized + lifecycleManager.markInitialized(plugin2) + + // Both should be initialized now + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeTrue()) + }) + + It("should handle plugins with same name but different versions", func() { + plugin1 := &plugin{ + ID: "test-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + plugin2 := &plugin{ + ID: "test-plugin", // Same name + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "2.0.0", // Different version + }, + } + + // Mark v1 as initialized + lifecycleManager.markInitialized(plugin1) + + // v1 should be initialized but not v2 + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse()) + + // Mark v2 as initialized + lifecycleManager.markInitialized(plugin2) + + // Both versions should be initialized now + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeTrue()) + + // Verify the keys used for tracking + key1 := plugin1.ID + consts.Zwsp + plugin1.Manifest.Version + key2 := plugin1.ID + consts.Zwsp + plugin2.Manifest.Version + _, exists1 := lifecycleManager.plugins.Load(key1) + _, exists2 := lifecycleManager.plugins.Load(key2) + Expect(exists1).To(BeTrue()) + Expect(exists2).To(BeTrue()) + Expect(key1).NotTo(Equal(key2)) + }) + + It("should only consider plugins that implement LifecycleManagement", func() { + // Plugin that implements LifecycleManagement + initPlugin := &plugin{ + ID: "init-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + // Plugin that doesn't implement LifecycleManagement + regularPlugin := &plugin{ + ID: "regular-plugin", + Capabilities: []string{"MetadataAgent"}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + // Check if plugins can be initialized + Expect(hasInitService(initPlugin)).To(BeTrue()) + Expect(hasInitService(regularPlugin)).To(BeFalse()) + }) + + It("should properly construct the plugin key", func() { + plugin := &plugin{ + ID: "test-plugin", + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + expectedKey := "test-plugin" + consts.Zwsp + "1.0.0" + actualKey := plugin.ID + consts.Zwsp + plugin.Manifest.Version + + Expect(actualKey).To(Equal(expectedKey)) + }) + }) +}) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go new file mode 100644 index 000000000..153426317 --- /dev/null +++ b/plugins/plugins_suite_test.go @@ -0,0 +1,32 @@ +package plugins + +import ( + "os/exec" + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const testDataDir = "plugins/testdata" + +func TestPlugins(t *testing.T) { + tests.Init(t, false) + buildTestPlugins(t, testDataDir) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Suite") +} + +func buildTestPlugins(t *testing.T, path string) { + t.Helper() + t.Logf("[BeforeSuite] Current working directory: %s", path) + cmd := exec.Command("make", "-C", path) + out, err := cmd.CombinedOutput() + t.Logf("[BeforeSuite] Make output: %s", string(out)) + if err != nil { + t.Fatalf("Failed to build test plugins: %v", err) + } +} diff --git a/plugins/runtime.go b/plugins/runtime.go new file mode 100644 index 000000000..f68175efc --- /dev/null +++ b/plugins/runtime.go @@ -0,0 +1,626 @@ +package plugins + +import ( + "context" + "crypto/md5" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/artwork" + "github.com/navidrome/navidrome/plugins/host/cache" + "github.com/navidrome/navidrome/plugins/host/config" + "github.com/navidrome/navidrome/plugins/host/http" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" + "github.com/navidrome/navidrome/plugins/host/websocket" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/tetratelabs/wazero" + wazeroapi "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +const maxParallelCompilations = 2 // Limit to 2 concurrent compilations + +var ( + compileSemaphore = make(chan struct{}, maxParallelCompilations) + compilationCache wazero.CompilationCache + cacheOnce sync.Once + runtimePool sync.Map // map[string]*cachingRuntime +) + +// createRuntime returns a function that creates a new wazero runtime and instantiates the required host functions +// based on the given plugin permissions +func (m *Manager) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime { + return func(ctx context.Context) (wazero.Runtime, error) { + // Check if runtime already exists + if rt, ok := runtimePool.Load(pluginID); ok { + log.Trace(ctx, "Using existing runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", rt)) + // Return a new wrapper for each call, so each instance gets its own module capture + return newScopedRuntime(rt.(wazero.Runtime)), nil + } + + // Create new runtime with all the setup + cachingRT, err := m.createCachingRuntime(ctx, pluginID, permissions) + if err != nil { + return nil, err + } + + // Use LoadOrStore to atomically check and store, preventing race conditions + if existing, loaded := runtimePool.LoadOrStore(pluginID, cachingRT); loaded { + // Another goroutine created the runtime first, close ours and return the existing one + log.Trace(ctx, "Race condition detected, using existing runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", existing)) + _ = cachingRT.Close(ctx) + return newScopedRuntime(existing.(wazero.Runtime)), nil + } + + log.Trace(ctx, "Created new runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", cachingRT)) + return newScopedRuntime(cachingRT), nil + } +} + +// createCachingRuntime handles the complex logic of setting up a new cachingRuntime +func (m *Manager) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) { + // Get compilation cache + compCache, err := getCompilationCache() + if err != nil { + return nil, fmt.Errorf("failed to get compilation cache: %w", err) + } + + // Create the runtime + runtimeConfig := wazero.NewRuntimeConfig().WithCompilationCache(compCache) + r := wazero.NewRuntimeWithConfig(ctx, runtimeConfig) + if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { + return nil, err + } + + // Setup host services + if err := m.setupHostServices(ctx, r, pluginID, permissions); err != nil { + _ = r.Close(ctx) + return nil, err + } + + return newCachingRuntime(r, pluginID), nil +} + +// setupHostServices configures all the permitted host services for a plugin +func (m *Manager) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error { + // Define all available host services + type hostService struct { + name string + isPermitted bool + loadFunc func() (map[string]wazeroapi.FunctionDefinition, error) + } + + // List of all available host services with their permissions and loading functions + availableServices := []hostService{ + {"config", permissions.Config != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[config.ConfigService](ctx, config.Instantiate, &configServiceImpl{pluginID: pluginID}) + }}, + {"scheduler", permissions.Scheduler != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[scheduler.SchedulerService](ctx, scheduler.Instantiate, m.schedulerService.HostFunctions(pluginID)) + }}, + {"cache", permissions.Cache != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[cache.CacheService](ctx, cache.Instantiate, newCacheService(pluginID)) + }}, + {"artwork", permissions.Artwork != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[artwork.ArtworkService](ctx, artwork.Instantiate, &artworkServiceImpl{}) + }}, + {"http", permissions.Http != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + httpPerms, err := parseHTTPPermissions(permissions.Http) + if err != nil { + return nil, fmt.Errorf("invalid http permissions for plugin %s: %w", pluginID, err) + } + return loadHostLibrary[http.HttpService](ctx, http.Instantiate, &httpServiceImpl{ + pluginID: pluginID, + permissions: httpPerms, + }) + }}, + {"websocket", permissions.Websocket != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + wsPerms, err := parseWebSocketPermissions(permissions.Websocket) + if err != nil { + return nil, fmt.Errorf("invalid websocket permissions for plugin %s: %w", pluginID, err) + } + return loadHostLibrary[websocket.WebSocketService](ctx, websocket.Instantiate, m.websocketService.HostFunctions(pluginID, wsPerms)) + }}, + {"subsonicapi", permissions.Subsonicapi != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + if router := m.subsonicRouter.Load(); router != nil { + service := newSubsonicAPIService(pluginID, m.subsonicRouter.Load(), m.ds, permissions.Subsonicapi) + return loadHostLibrary[subsonicapi.SubsonicAPIService](ctx, subsonicapi.Instantiate, service) + } + log.Error(ctx, "SubsonicAPI service requested but router not available", "plugin", pluginID) + return nil, fmt.Errorf("SubsonicAPI router not available for plugin %s", pluginID) + }}, + } + + // Load only permitted services + var grantedPermissions []string + var libraries []map[string]wazeroapi.FunctionDefinition + for _, service := range availableServices { + if service.isPermitted { + lib, err := service.loadFunc() + if err != nil { + return fmt.Errorf("error loading %s lib: %w", service.name, err) + } + libraries = append(libraries, lib) + grantedPermissions = append(grantedPermissions, service.name) + } + } + log.Trace(ctx, "Granting permissions for plugin", "plugin", pluginID, "permissions", grantedPermissions) + + // Combine the permitted libraries + return combineLibraries(ctx, r, libraries...) +} + +// purgeCacheBySize removes the oldest files in dir until its total size is +// lower than or equal to maxSize. maxSize should be a human-readable string +// like "10MB" or "200K". If parsing fails or maxSize is "0", the function is +// a no-op. +func purgeCacheBySize(dir, maxSize string) { + sizeLimit, err := humanize.ParseBytes(maxSize) + if err != nil || sizeLimit == 0 { + return + } + + type fileInfo struct { + path string + size uint64 + mod int64 + } + + var files []fileInfo + var total uint64 + + walk := func(path string, d fs.DirEntry, err error) error { + if err != nil { + log.Trace("Failed to access plugin cache entry", "path", path, err) + return nil //nolint:nilerr + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + log.Trace("Failed to get file info for plugin cache entry", "path", path, err) + return nil //nolint:nilerr + } + files = append(files, fileInfo{ + path: path, + size: uint64(info.Size()), + mod: info.ModTime().UnixMilli(), + }) + total += uint64(info.Size()) + return nil + } + + if err := filepath.WalkDir(dir, walk); err != nil { + if !os.IsNotExist(err) { + log.Warn("Failed to traverse plugin cache directory", "path", dir, err) + } + return + } + + log.Trace("Current plugin cache size", "path", dir, "size", humanize.Bytes(total), "sizeLimit", humanize.Bytes(sizeLimit)) + if total <= sizeLimit { + return + } + + log.Debug("Purging plugin cache", "path", dir, "sizeLimit", humanize.Bytes(sizeLimit), "currentSize", humanize.Bytes(total)) + sort.Slice(files, func(i, j int) bool { return files[i].mod < files[j].mod }) + for _, f := range files { + if total <= sizeLimit { + break + } + if err := os.Remove(f.path); err != nil { + log.Warn("Failed to remove plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), err) + continue + } + total -= f.size + log.Debug("Removed plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), "time", time.UnixMilli(f.mod), "remainingSize", humanize.Bytes(total)) + + // Remove empty parent directories + dirPath := filepath.Dir(f.path) + for dirPath != dir { + if err := os.Remove(dirPath); err != nil { + break + } + dirPath = filepath.Dir(dirPath) + } + } +} + +// getCompilationCache returns the global compilation cache, creating it if necessary +func getCompilationCache() (wazero.CompilationCache, error) { + var err error + cacheOnce.Do(func() { + cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") + purgeCacheBySize(cacheDir, conf.Server.Plugins.CacheSize) + compilationCache, err = wazero.NewCompilationCacheWithDir(cacheDir) + }) + return compilationCache, err +} + +// newWazeroModuleConfig creates the correct ModuleConfig for plugins +func newWazeroModuleConfig() wazero.ModuleConfig { + return wazero.NewModuleConfig().WithStartFunctions("_initialize").WithStderr(log.Writer()) +} + +// pluginCompilationTimeout returns the timeout for plugin compilation +func pluginCompilationTimeout() time.Duration { + if conf.Server.DevPluginCompilationTimeout > 0 { + return conf.Server.DevPluginCompilationTimeout + } + return time.Minute +} + +// precompilePlugin compiles the WASM module in the background and updates the pluginState. +func precompilePlugin(p *plugin) { + compileSemaphore <- struct{}{} + defer func() { <-compileSemaphore }() + ctx := context.Background() + r, err := p.Runtime(ctx) + if err != nil { + p.compilationErr = fmt.Errorf("failed to create runtime for plugin %s: %w", p.ID, err) + close(p.compilationReady) + return + } + + b, err := os.ReadFile(p.WasmPath) + if err != nil { + p.compilationErr = fmt.Errorf("failed to read wasm file: %w", err) + close(p.compilationReady) + return + } + + // We know r is always a *scopedRuntime from createRuntime + scopedRT := r.(*scopedRuntime) + cachingRT := scopedRT.GetCachingRuntime() + if cachingRT == nil { + p.compilationErr = fmt.Errorf("failed to get cachingRuntime for plugin %s", p.ID) + close(p.compilationReady) + return + } + + _, err = cachingRT.CompileModule(ctx, b) + if err != nil { + p.compilationErr = fmt.Errorf("failed to compile WASM for plugin %s: %w", p.ID, err) + log.Warn("Plugin compilation failed", "name", p.ID, "path", p.WasmPath, "err", err) + } else { + p.compilationErr = nil + log.Debug("Plugin compilation completed", "name", p.ID, "path", p.WasmPath) + } + close(p.compilationReady) +} + +// loadHostLibrary loads the given host library and returns its exported functions +func loadHostLibrary[S any]( + ctx context.Context, + instantiateFn func(context.Context, wazero.Runtime, S) error, + service S, +) (map[string]wazeroapi.FunctionDefinition, error) { + r := wazero.NewRuntime(ctx) + if err := instantiateFn(ctx, r, service); err != nil { + return nil, err + } + m := r.Module("env") + return m.ExportedFunctionDefinitions(), nil +} + +// combineLibraries combines the given host libraries into a single "env" module +func combineLibraries(ctx context.Context, r wazero.Runtime, libs ...map[string]wazeroapi.FunctionDefinition) error { + // Merge the libraries + hostLib := map[string]wazeroapi.FunctionDefinition{} + for _, lib := range libs { + maps.Copy(hostLib, lib) + } + + // Create the combined host module + envBuilder := r.NewHostModuleBuilder("env") + for name, fd := range hostLib { + fn, ok := fd.GoFunction().(wazeroapi.GoModuleFunction) + if !ok { + return fmt.Errorf("invalid function definition: %s", fd.DebugName()) + } + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(fn, fd.ParamTypes(), fd.ResultTypes()). + WithParameterNames(fd.ParamNames()...).Export(name) + } + + // Instantiate the combined host module + if _, err := envBuilder.Instantiate(ctx); err != nil { + return err + } + return nil +} + +const ( + // WASM Instance pool configuration + // defaultPoolSize is the maximum number of instances per plugin that are kept in the pool for reuse + defaultPoolSize = 8 + // defaultInstanceTTL is the time after which an instance is considered stale and can be evicted + defaultInstanceTTL = time.Minute + // defaultMaxConcurrentInstances is the hard limit on total instances that can exist simultaneously + defaultMaxConcurrentInstances = 10 + // defaultGetTimeout is the maximum time to wait when getting an instance if at the concurrent limit + defaultGetTimeout = 5 * time.Second + + // Compiled module cache configuration + // defaultCompiledModuleTTL is the time after which a compiled module is evicted from the cache + defaultCompiledModuleTTL = 5 * time.Minute +) + +// cachedCompiledModule encapsulates a compiled WebAssembly module with TTL management +type cachedCompiledModule struct { + module wazero.CompiledModule + hash [16]byte + lastAccess time.Time + timer *time.Timer + mu sync.Mutex + pluginID string // for logging purposes +} + +// newCachedCompiledModule creates a new cached compiled module with TTL management +func newCachedCompiledModule(module wazero.CompiledModule, wasmBytes []byte, pluginID string) *cachedCompiledModule { + c := &cachedCompiledModule{ + module: module, + hash: md5.Sum(wasmBytes), + lastAccess: time.Now(), + pluginID: pluginID, + } + + // Set up the TTL timer + c.timer = time.AfterFunc(defaultCompiledModuleTTL, c.evict) + + return c +} + +// get returns the cached module if the hash matches, nil otherwise +// Also resets the TTL timer on successful access +func (c *cachedCompiledModule) get(wasmHash [16]byte) wazero.CompiledModule { + c.mu.Lock() // Use write lock because we modify state in resetTimer + defer c.mu.Unlock() + + if c.module != nil && c.hash == wasmHash { + // Reset TTL timer on access + c.resetTimer() + return c.module + } + + return nil +} + +// resetTimer resets the TTL timer (must be called with lock held) +func (c *cachedCompiledModule) resetTimer() { + c.lastAccess = time.Now() + + if c.timer != nil { + c.timer.Stop() + c.timer = time.AfterFunc(defaultCompiledModuleTTL, c.evict) + } +} + +// evict removes the cached module and cleans up resources +func (c *cachedCompiledModule) evict() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.module != nil { + log.Trace("cachedCompiledModule: evicting due to TTL expiry", "plugin", c.pluginID, "ttl", defaultCompiledModuleTTL) + c.module.Close(context.Background()) + c.module = nil + c.hash = [16]byte{} + c.lastAccess = time.Time{} + } + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } +} + +// close cleans up the cached module and stops the timer +func (c *cachedCompiledModule) close(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } + + if c.module != nil { + c.module.Close(ctx) + c.module = nil + } +} + +// pooledModule wraps a wazero Module and returns it to the pool when closed. +type pooledModule struct { + wazeroapi.Module + pool *wasmInstancePool[wazeroapi.Module] + closed bool +} + +func (m *pooledModule) Close(ctx context.Context) error { + if !m.closed { + m.closed = true + m.pool.Put(ctx, m.Module) + } + return nil +} + +func (m *pooledModule) CloseWithExitCode(ctx context.Context, exitCode uint32) error { + return m.Close(ctx) +} + +func (m *pooledModule) IsClosed() bool { + return m.closed +} + +// newScopedRuntime creates a new scopedRuntime that wraps the given runtime +func newScopedRuntime(runtime wazero.Runtime) *scopedRuntime { + return &scopedRuntime{Runtime: runtime} +} + +// scopedRuntime wraps a cachingRuntime and captures a specific module +// so that Close() only affects that module, not the entire shared runtime +type scopedRuntime struct { + wazero.Runtime + capturedModule wazeroapi.Module +} + +func (w *scopedRuntime) InstantiateModule(ctx context.Context, code wazero.CompiledModule, config wazero.ModuleConfig) (wazeroapi.Module, error) { + module, err := w.Runtime.InstantiateModule(ctx, code, config) + if err != nil { + return nil, err + } + // Capture the module for later cleanup + w.capturedModule = module + log.Trace(ctx, "scopedRuntime: captured module", "moduleID", getInstanceID(module)) + return module, nil +} + +func (w *scopedRuntime) Close(ctx context.Context) error { + // Close only the captured module, not the entire runtime + if w.capturedModule != nil { + log.Trace(ctx, "scopedRuntime: closing captured module", "moduleID", getInstanceID(w.capturedModule)) + return w.capturedModule.Close(ctx) + } + log.Trace(ctx, "scopedRuntime: no captured module to close") + return nil +} + +func (w *scopedRuntime) CloseWithExitCode(ctx context.Context, exitCode uint32) error { + return w.Close(ctx) +} + +// GetCachingRuntime returns the underlying cachingRuntime for internal use +func (w *scopedRuntime) GetCachingRuntime() *cachingRuntime { + if cr, ok := w.Runtime.(*cachingRuntime); ok { + return cr + } + return nil +} + +// cachingRuntime wraps wazero.Runtime and pools module instances per plugin, +// while also caching the compiled module in memory. +type cachingRuntime struct { + wazero.Runtime + + // pluginID is required to differentiate between different plugins that use the same file to initialize their + // runtime. The runtime will serve as a singleton for all instances of a given plugin. + pluginID string + + // cachedModule manages the compiled module cache with TTL + cachedModule atomic.Pointer[cachedCompiledModule] + + // pool manages reusable module instances + pool *wasmInstancePool[wazeroapi.Module] + + // poolInitOnce ensures the pool is initialized only once + poolInitOnce sync.Once + + // compilationMu ensures only one compilation happens at a time per runtime + compilationMu sync.Mutex +} + +func newCachingRuntime(runtime wazero.Runtime, pluginID string) *cachingRuntime { + return &cachingRuntime{ + Runtime: runtime, + pluginID: pluginID, + } +} + +func (r *cachingRuntime) initPool(code wazero.CompiledModule, config wazero.ModuleConfig) { + r.poolInitOnce.Do(func() { + r.pool = newWasmInstancePool[wazeroapi.Module](r.pluginID, defaultPoolSize, defaultMaxConcurrentInstances, defaultGetTimeout, defaultInstanceTTL, func(ctx context.Context) (wazeroapi.Module, error) { + log.Trace(ctx, "cachingRuntime: creating new module instance", "plugin", r.pluginID) + return r.Runtime.InstantiateModule(ctx, code, config) + }) + }) +} + +func (r *cachingRuntime) InstantiateModule(ctx context.Context, code wazero.CompiledModule, config wazero.ModuleConfig) (wazeroapi.Module, error) { + r.initPool(code, config) + mod, err := r.pool.Get(ctx) + if err != nil { + return nil, err + } + wrapped := &pooledModule{Module: mod, pool: r.pool} + log.Trace(ctx, "cachingRuntime: created wrapper for module", "plugin", r.pluginID, "underlyingModuleID", fmt.Sprintf("%p", mod), "wrapperID", fmt.Sprintf("%p", wrapped)) + return wrapped, nil +} + +func (r *cachingRuntime) Close(ctx context.Context) error { + log.Trace(ctx, "cachingRuntime: closing runtime", "plugin", r.pluginID) + + // Clean up compiled module cache + if cached := r.cachedModule.Swap(nil); cached != nil { + cached.close(ctx) + } + + // Close the instance pool + if r.pool != nil { + r.pool.Close(ctx) + } + // Close the underlying runtime + return r.Runtime.Close(ctx) +} + +// setCachedModule stores a newly compiled module in the cache with TTL management +func (r *cachingRuntime) setCachedModule(module wazero.CompiledModule, wasmBytes []byte) { + newCached := newCachedCompiledModule(module, wasmBytes, r.pluginID) + + // Replace old cached module and clean it up + if old := r.cachedModule.Swap(newCached); old != nil { + old.close(context.Background()) + } +} + +// CompileModule checks if the provided bytes match our cached hash and returns +// the cached compiled module if so, avoiding both file read and compilation. +func (r *cachingRuntime) CompileModule(ctx context.Context, wasmBytes []byte) (wazero.CompiledModule, error) { + incomingHash := md5.Sum(wasmBytes) + + // Try to get from cache first (without lock for performance) + if cached := r.cachedModule.Load(); cached != nil { + if module := cached.get(incomingHash); module != nil { + log.Trace(ctx, "cachingRuntime: using cached compiled module", "plugin", r.pluginID) + return module, nil + } + } + + // Synchronize compilation to prevent concurrent compilation issues + r.compilationMu.Lock() + defer r.compilationMu.Unlock() + + // Double-check cache after acquiring lock (another goroutine might have compiled it) + if cached := r.cachedModule.Load(); cached != nil { + if module := cached.get(incomingHash); module != nil { + log.Trace(ctx, "cachingRuntime: using cached compiled module (after lock)", "plugin", r.pluginID) + return module, nil + } + } + + // Fall back to normal compilation for different bytes + log.Trace(ctx, "cachingRuntime: hash doesn't match cache, compiling normally", "plugin", r.pluginID) + module, err := r.Runtime.CompileModule(ctx, wasmBytes) + if err != nil { + return nil, err + } + + // Cache the newly compiled module + r.setCachedModule(module, wasmBytes) + + return module, nil +} diff --git a/plugins/runtime_test.go b/plugins/runtime_test.go new file mode 100644 index 000000000..32cd42118 --- /dev/null +++ b/plugins/runtime_test.go @@ -0,0 +1,172 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/tetratelabs/wazero" +) + +var _ = Describe("Runtime", func() { + Describe("pluginCompilationTimeout", func() { + It("should use DevPluginCompilationTimeout config for plugin compilation timeout", func() { + originalTimeout := conf.Server.DevPluginCompilationTimeout + DeferCleanup(func() { + conf.Server.DevPluginCompilationTimeout = originalTimeout + }) + + conf.Server.DevPluginCompilationTimeout = 123 * time.Second + Expect(pluginCompilationTimeout()).To(Equal(123 * time.Second)) + + conf.Server.DevPluginCompilationTimeout = 0 + Expect(pluginCompilationTimeout()).To(Equal(time.Minute)) + }) + }) +}) + +var _ = Describe("CachingRuntime", func() { + var ( + ctx context.Context + mgr *Manager + plugin *wasmScrobblerPlugin + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + mgr = createManager(nil, nil) + // Add permissions for the test plugin using typed struct + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "For testing HTTP functionality", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "For testing config functionality", + }, + } + rtFunc := mgr.createRuntime("fake_scrobbler", permissions) + plugin = newWasmScrobblerPlugin( + filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"), + "fake_scrobbler", + mgr, + rtFunc, + wazero.NewModuleConfig().WithStartFunctions("_initialize"), + ).(*wasmScrobblerPlugin) + // runtime will be created on first plugin load + }) + + It("reuses module instances across calls", func() { + // First call to create the runtime and pool + _, done, err := plugin.getInstance(ctx, "first") + Expect(err).ToNot(HaveOccurred()) + done() + + val, ok := runtimePool.Load("fake_scrobbler") + Expect(ok).To(BeTrue()) + cachingRT := val.(*cachingRuntime) + + // Verify the pool exists and is initialized + Expect(cachingRT.pool).ToNot(BeNil()) + + // Test that multiple calls work without error (indicating pool reuse) + for i := 0; i < 5; i++ { + inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("call_%d", i)) + Expect(err).ToNot(HaveOccurred()) + Expect(inst).ToNot(BeNil()) + done() + } + + // Test concurrent access to verify pool handles concurrency + const numGoroutines = 3 + errChan := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("concurrent_%d", id)) + if err != nil { + errChan <- err + return + } + defer done() + + // Verify we got a valid instance + if inst == nil { + errChan <- fmt.Errorf("got nil instance") + return + } + errChan <- nil + }(i) + } + + // Check all goroutines succeeded + for i := 0; i < numGoroutines; i++ { + err := <-errChan + Expect(err).To(BeNil()) + } + }) +}) + +var _ = Describe("purgeCacheBySize", func() { + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "cache_test") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(os.RemoveAll, tmpDir) + }) + + It("removes oldest entries when above the size limit", func() { + oldDir := filepath.Join(tmpDir, "d1") + newDir := filepath.Join(tmpDir, "d2") + Expect(os.Mkdir(oldDir, 0700)).To(Succeed()) + Expect(os.Mkdir(newDir, 0700)).To(Succeed()) + + oldFile := filepath.Join(oldDir, "old") + newFile := filepath.Join(newDir, "new") + Expect(os.WriteFile(oldFile, []byte("xx"), 0600)).To(Succeed()) + Expect(os.WriteFile(newFile, []byte("xx"), 0600)).To(Succeed()) + + oldTime := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(oldFile, oldTime, oldTime)).To(Succeed()) + + purgeCacheBySize(tmpDir, "3") + + _, err := os.Stat(oldFile) + Expect(os.IsNotExist(err)).To(BeTrue()) + _, err = os.Stat(oldDir) + Expect(os.IsNotExist(err)).To(BeTrue()) + + _, err = os.Stat(newFile) + Expect(err).ToNot(HaveOccurred()) + }) + + It("does nothing when below the size limit", func() { + dir1 := filepath.Join(tmpDir, "a") + dir2 := filepath.Join(tmpDir, "b") + Expect(os.Mkdir(dir1, 0700)).To(Succeed()) + Expect(os.Mkdir(dir2, 0700)).To(Succeed()) + + file1 := filepath.Join(dir1, "f1") + file2 := filepath.Join(dir2, "f2") + Expect(os.WriteFile(file1, []byte("x"), 0600)).To(Succeed()) + Expect(os.WriteFile(file2, []byte("x"), 0600)).To(Succeed()) + + purgeCacheBySize(tmpDir, "10MB") + + _, err := os.Stat(file1) + Expect(err).ToNot(HaveOccurred()) + _, err = os.Stat(file2) + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/plugins/schema/manifest.schema.json b/plugins/schema/manifest.schema.json new file mode 100644 index 000000000..0c323126b --- /dev/null +++ b/plugins/schema/manifest.schema.json @@ -0,0 +1,199 @@ +{ + "$id": "navidrome://plugins/manifest", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Navidrome Plugin Manifest", + "description": "Schema for Navidrome Plugin manifest.json files", + "type": "object", + "required": [ + "name", + "author", + "version", + "description", + "website", + "capabilities", + "permissions" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the plugin" + }, + "author": { + "type": "string", + "description": "Author or organization that created the plugin" + }, + "version": { + "type": "string", + "description": "Plugin version using semantic versioning format" + }, + "description": { + "type": "string", + "description": "A brief description of the plugin's functionality" + }, + "website": { + "type": "string", + "format": "uri", + "description": "Website URL for the plugin or its documentation" + }, + "capabilities": { + "type": "array", + "description": "List of capabilities implemented by this plugin", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "MetadataAgent", + "Scrobbler", + "SchedulerCallback", + "LifecycleManagement", + "WebSocketCallback" + ] + } + }, + "permissions": { + "type": "object", + "description": "Host services the plugin is allowed to access", + "additionalProperties": true, + "properties": { + "http": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "HTTP service permissions", + "required": ["allowedUrls"], + "properties": { + "allowedUrls": { + "type": "object", + "description": "Map of URL patterns (e.g., 'https://api.example.com/*') to allowed HTTP methods. Redirect destinations must also be included.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "*" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "minProperties": 1 + }, + "allowLocalNetwork": { + "type": "boolean", + "description": "Whether to allow requests to local/private network addresses", + "default": false + } + } + } + ] + }, + "config": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Configuration service permissions" + } + ] + }, + "scheduler": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Scheduler service permissions" + } + ] + }, + "websocket": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "WebSocket service permissions", + "required": ["allowedUrls"], + "properties": { + "allowedUrls": { + "type": "array", + "description": "List of WebSocket URL patterns that the plugin is allowed to connect to", + "items": { + "type": "string", + "pattern": "^wss?://.*$" + }, + "minItems": 1, + "uniqueItems": true + }, + "allowLocalNetwork": { + "type": "boolean", + "description": "Whether to allow connections to local/private network addresses", + "default": false + } + } + } + ] + }, + "cache": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Cache service permissions" + } + ] + }, + "artwork": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Artwork service permissions" + } + ] + }, + "subsonicapi": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "SubsonicAPI service permissions", + "properties": { + "allowedUsernames": { + "type": "array", + "description": "List of usernames the plugin can pass as u. Any user if empty", + "items": { "type": "string" } + }, + "allowAdmins": { + "type": "boolean", + "description": "If false, reject calls where the u is an admin", + "default": false + } + } + } + ] + } + } + } + }, + "$defs": { + "basePermission": { + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "description": "Explanation of why this permission is needed" + } + }, + "additionalProperties": false + } + } +} diff --git a/plugins/schema/manifest_gen.go b/plugins/schema/manifest_gen.go new file mode 100644 index 000000000..97e07a077 --- /dev/null +++ b/plugins/schema/manifest_gen.go @@ -0,0 +1,426 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package schema + +import "encoding/json" +import "fmt" +import "reflect" + +type BasePermission struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *BasePermission) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in BasePermission: required") + } + type Plain BasePermission + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = BasePermission(plain) + return nil +} + +// Schema for Navidrome Plugin manifest.json files +type PluginManifest struct { + // Author or organization that created the plugin + Author string `json:"author" yaml:"author" mapstructure:"author"` + + // List of capabilities implemented by this plugin + Capabilities []PluginManifestCapabilitiesElem `json:"capabilities" yaml:"capabilities" mapstructure:"capabilities"` + + // A brief description of the plugin's functionality + Description string `json:"description" yaml:"description" mapstructure:"description"` + + // Name of the plugin + Name string `json:"name" yaml:"name" mapstructure:"name"` + + // Host services the plugin is allowed to access + Permissions PluginManifestPermissions `json:"permissions" yaml:"permissions" mapstructure:"permissions"` + + // Plugin version using semantic versioning format + Version string `json:"version" yaml:"version" mapstructure:"version"` + + // Website URL for the plugin or its documentation + Website string `json:"website" yaml:"website" mapstructure:"website"` +} + +type PluginManifestCapabilitiesElem string + +const PluginManifestCapabilitiesElemLifecycleManagement PluginManifestCapabilitiesElem = "LifecycleManagement" +const PluginManifestCapabilitiesElemMetadataAgent PluginManifestCapabilitiesElem = "MetadataAgent" +const PluginManifestCapabilitiesElemSchedulerCallback PluginManifestCapabilitiesElem = "SchedulerCallback" +const PluginManifestCapabilitiesElemScrobbler PluginManifestCapabilitiesElem = "Scrobbler" +const PluginManifestCapabilitiesElemWebSocketCallback PluginManifestCapabilitiesElem = "WebSocketCallback" + +var enumValues_PluginManifestCapabilitiesElem = []interface{}{ + "MetadataAgent", + "Scrobbler", + "SchedulerCallback", + "LifecycleManagement", + "WebSocketCallback", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestCapabilitiesElem) UnmarshalJSON(value []byte) error { + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_PluginManifestCapabilitiesElem { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_PluginManifestCapabilitiesElem, v) + } + *j = PluginManifestCapabilitiesElem(v) + return nil +} + +// Host services the plugin is allowed to access +type PluginManifestPermissions struct { + // Artwork corresponds to the JSON schema field "artwork". + Artwork *PluginManifestPermissionsArtwork `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"` + + // Cache corresponds to the JSON schema field "cache". + Cache *PluginManifestPermissionsCache `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"` + + // Config corresponds to the JSON schema field "config". + Config *PluginManifestPermissionsConfig `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"` + + // Http corresponds to the JSON schema field "http". + Http *PluginManifestPermissionsHttp `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` + + // Scheduler corresponds to the JSON schema field "scheduler". + Scheduler *PluginManifestPermissionsScheduler `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + + // Subsonicapi corresponds to the JSON schema field "subsonicapi". + Subsonicapi *PluginManifestPermissionsSubsonicapi `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` + + // Websocket corresponds to the JSON schema field "websocket". + Websocket *PluginManifestPermissionsWebsocket `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"` + + AdditionalProperties interface{} `mapstructure:",remain"` +} + +// Artwork service permissions +type PluginManifestPermissionsArtwork struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsArtwork) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsArtwork: required") + } + type Plain PluginManifestPermissionsArtwork + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsArtwork(plain) + return nil +} + +// Cache service permissions +type PluginManifestPermissionsCache struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsCache) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsCache: required") + } + type Plain PluginManifestPermissionsCache + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsCache(plain) + return nil +} + +// Configuration service permissions +type PluginManifestPermissionsConfig struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsConfig) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsConfig: required") + } + type Plain PluginManifestPermissionsConfig + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsConfig(plain) + return nil +} + +// HTTP service permissions +type PluginManifestPermissionsHttp struct { + // Whether to allow requests to local/private network addresses + AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty" yaml:"allowLocalNetwork,omitempty" mapstructure:"allowLocalNetwork,omitempty"` + + // Map of URL patterns (e.g., 'https://api.example.com/*') to allowed HTTP + // methods. Redirect destinations must also be included. + AllowedUrls map[string][]PluginManifestPermissionsHttpAllowedUrlsValueElem `json:"allowedUrls" yaml:"allowedUrls" mapstructure:"allowedUrls"` + + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +type PluginManifestPermissionsHttpAllowedUrlsValueElem string + +const PluginManifestPermissionsHttpAllowedUrlsValueElemDELETE PluginManifestPermissionsHttpAllowedUrlsValueElem = "DELETE" +const PluginManifestPermissionsHttpAllowedUrlsValueElemGET PluginManifestPermissionsHttpAllowedUrlsValueElem = "GET" +const PluginManifestPermissionsHttpAllowedUrlsValueElemHEAD PluginManifestPermissionsHttpAllowedUrlsValueElem = "HEAD" +const PluginManifestPermissionsHttpAllowedUrlsValueElemOPTIONS PluginManifestPermissionsHttpAllowedUrlsValueElem = "OPTIONS" +const PluginManifestPermissionsHttpAllowedUrlsValueElemPATCH PluginManifestPermissionsHttpAllowedUrlsValueElem = "PATCH" +const PluginManifestPermissionsHttpAllowedUrlsValueElemPOST PluginManifestPermissionsHttpAllowedUrlsValueElem = "POST" +const PluginManifestPermissionsHttpAllowedUrlsValueElemPUT PluginManifestPermissionsHttpAllowedUrlsValueElem = "PUT" +const PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard PluginManifestPermissionsHttpAllowedUrlsValueElem = "*" + +var enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem = []interface{}{ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "*", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsHttpAllowedUrlsValueElem) UnmarshalJSON(value []byte) error { + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem, v) + } + *j = PluginManifestPermissionsHttpAllowedUrlsValueElem(v) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsHttp) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["allowedUrls"]; raw != nil && !ok { + return fmt.Errorf("field allowedUrls in PluginManifestPermissionsHttp: required") + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsHttp: required") + } + type Plain PluginManifestPermissionsHttp + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["allowLocalNetwork"]; !ok || v == nil { + plain.AllowLocalNetwork = false + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsHttp(plain) + return nil +} + +// Scheduler service permissions +type PluginManifestPermissionsScheduler struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsScheduler) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsScheduler: required") + } + type Plain PluginManifestPermissionsScheduler + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsScheduler(plain) + return nil +} + +// SubsonicAPI service permissions +type PluginManifestPermissionsSubsonicapi struct { + // If false, reject calls where the u is an admin + AllowAdmins bool `json:"allowAdmins,omitempty" yaml:"allowAdmins,omitempty" mapstructure:"allowAdmins,omitempty"` + + // List of usernames the plugin can pass as u. Any user if empty + AllowedUsernames []string `json:"allowedUsernames,omitempty" yaml:"allowedUsernames,omitempty" mapstructure:"allowedUsernames,omitempty"` + + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsSubsonicapi) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsSubsonicapi: required") + } + type Plain PluginManifestPermissionsSubsonicapi + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["allowAdmins"]; !ok || v == nil { + plain.AllowAdmins = false + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsSubsonicapi(plain) + return nil +} + +// WebSocket service permissions +type PluginManifestPermissionsWebsocket struct { + // Whether to allow connections to local/private network addresses + AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty" yaml:"allowLocalNetwork,omitempty" mapstructure:"allowLocalNetwork,omitempty"` + + // List of WebSocket URL patterns that the plugin is allowed to connect to + AllowedUrls []string `json:"allowedUrls" yaml:"allowedUrls" mapstructure:"allowedUrls"` + + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsWebsocket) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["allowedUrls"]; raw != nil && !ok { + return fmt.Errorf("field allowedUrls in PluginManifestPermissionsWebsocket: required") + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsWebsocket: required") + } + type Plain PluginManifestPermissionsWebsocket + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["allowLocalNetwork"]; !ok || v == nil { + plain.AllowLocalNetwork = false + } + if plain.AllowedUrls != nil && len(plain.AllowedUrls) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "allowedUrls", 1) + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsWebsocket(plain) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifest) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["author"]; raw != nil && !ok { + return fmt.Errorf("field author in PluginManifest: required") + } + if _, ok := raw["capabilities"]; raw != nil && !ok { + return fmt.Errorf("field capabilities in PluginManifest: required") + } + if _, ok := raw["description"]; raw != nil && !ok { + return fmt.Errorf("field description in PluginManifest: required") + } + if _, ok := raw["name"]; raw != nil && !ok { + return fmt.Errorf("field name in PluginManifest: required") + } + if _, ok := raw["permissions"]; raw != nil && !ok { + return fmt.Errorf("field permissions in PluginManifest: required") + } + if _, ok := raw["version"]; raw != nil && !ok { + return fmt.Errorf("field version in PluginManifest: required") + } + if _, ok := raw["website"]; raw != nil && !ok { + return fmt.Errorf("field website in PluginManifest: required") + } + type Plain PluginManifest + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if plain.Capabilities != nil && len(plain.Capabilities) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "capabilities", 1) + } + *j = PluginManifest(plain) + return nil +} diff --git a/plugins/testdata/.gitignore b/plugins/testdata/.gitignore new file mode 100644 index 000000000..917660a34 --- /dev/null +++ b/plugins/testdata/.gitignore @@ -0,0 +1 @@ +*.wasm \ No newline at end of file diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile new file mode 100644 index 000000000..f569cfce5 --- /dev/null +++ b/plugins/testdata/Makefile @@ -0,0 +1,10 @@ +# Fake sample plugins used for testing +PLUGINS := fake_album_agent fake_artist_agent fake_scrobbler multi_plugin fake_init_service unauthorized_plugin + +all: $(PLUGINS:%=%/plugin.wasm) + +clean: + rm -f $(PLUGINS:%=%/plugin.wasm) + +%/plugin.wasm: %/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./$* \ No newline at end of file diff --git a/plugins/testdata/README.md b/plugins/testdata/README.md new file mode 100644 index 000000000..abe840ff8 --- /dev/null +++ b/plugins/testdata/README.md @@ -0,0 +1,17 @@ +# Plugin Test Data + +This directory contains test data and mock implementations used for testing the Navidrome plugin system. + +## Contents + +Each of these directories contains the source code for a simple Go plugin that implements a specific agent interface +(or multiple interfaces in the case of `multi_plugin`). These are compiled into WASM modules using the +`Makefile` and used in integration tests for the plugin adapters (e.g., `adapter_media_agent_test.go`). + +Running `make` within this directory will build all test plugins. + +## Usage + +The primary use of this directory is during the development and testing phase. The `Makefile` is used to build the +necessary WASM plugin binaries. The tests within the `plugins` package (and potentially other packages that interact +with plugins) then utilize these compiled plugins and other test fixtures found here. diff --git a/plugins/testdata/fake_album_agent/manifest.json b/plugins/testdata/fake_album_agent/manifest.json new file mode 100644 index 000000000..e8dfb1fb3 --- /dev/null +++ b/plugins/testdata/fake_album_agent/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_album_agent", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for album agent", + "website": "https://test.navidrome.org/fake-album-agent", + "capabilities": ["MetadataAgent"], + "permissions": {} +} diff --git a/plugins/testdata/fake_album_agent/plugin.go b/plugins/testdata/fake_album_agent/plugin.go new file mode 100644 index 000000000..c35e90397 --- /dev/null +++ b/plugins/testdata/fake_album_agent/plugin.go @@ -0,0 +1,70 @@ +//go:build wasip1 + +package main + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/api" +) + +type FakeAlbumAgent struct{} + +var ErrNotFound = api.ErrNotFound + +func (FakeAlbumAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + if req.Name != "" && req.Artist != "" { + return &api.AlbumInfoResponse{ + Info: &api.AlbumInfo{ + Name: req.Name, + Mbid: "album-mbid-123", + Description: "This is a test album description", + Url: "https://example.com/album", + }, + }, nil + } + return nil, ErrNotFound +} + +func (FakeAlbumAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + if req.Name != "" && req.Artist != "" { + return &api.AlbumImagesResponse{ + Images: []*api.ExternalImage{ + {Url: "https://example.com/album1.jpg", Size: 300}, + {Url: "https://example.com/album2.jpg", Size: 400}, + }, + }, nil + } + return nil, ErrNotFound +} + +func (FakeAlbumAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, api.ErrNotImplemented +} + +func main() {} + +// Register the plugin implementation +func init() { + api.RegisterMetadataAgent(FakeAlbumAgent{}) +} diff --git a/plugins/testdata/fake_artist_agent/manifest.json b/plugins/testdata/fake_artist_agent/manifest.json new file mode 100644 index 000000000..c5db72565 --- /dev/null +++ b/plugins/testdata/fake_artist_agent/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_artist_agent", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for artist agent", + "website": "https://test.navidrome.org/fake-artist-agent", + "capabilities": ["MetadataAgent"], + "permissions": {} +} diff --git a/plugins/testdata/fake_artist_agent/plugin.go b/plugins/testdata/fake_artist_agent/plugin.go new file mode 100644 index 000000000..bd6b0f771 --- /dev/null +++ b/plugins/testdata/fake_artist_agent/plugin.go @@ -0,0 +1,82 @@ +//go:build wasip1 + +package main + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/api" +) + +type FakeArtistAgent struct{} + +var ErrNotFound = api.ErrNotFound + +func (FakeArtistAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + if req.Name != "" { + return &api.ArtistMBIDResponse{Mbid: "1234567890"}, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + if req.Name != "" { + return &api.ArtistURLResponse{Url: "https://example.com"}, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + if req.Name != "" { + return &api.ArtistBiographyResponse{Biography: "This is a test biography"}, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + if req.Name != "" { + return &api.ArtistSimilarResponse{ + Artists: []*api.Artist{ + {Name: "Similar Artist 1", Mbid: "mbid1"}, + {Name: "Similar Artist 2", Mbid: "mbid2"}, + }, + }, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + if req.Name != "" { + return &api.ArtistImageResponse{ + Images: []*api.ExternalImage{ + {Url: "https://example.com/image1.jpg", Size: 100}, + {Url: "https://example.com/image2.jpg", Size: 200}, + }, + }, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + if req.ArtistName != "" { + return &api.ArtistTopSongsResponse{ + Songs: []*api.Song{ + {Name: "Song 1", Mbid: "mbid1"}, + {Name: "Song 2", Mbid: "mbid2"}, + }, + }, nil + } + return nil, ErrNotFound +} + +// Add empty implementations for the album methods to satisfy the MetadataAgent interface +func (FakeArtistAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeArtistAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return nil, api.ErrNotImplemented +} + +// main is required by Go WASI build +func main() {} + +// init is used by go-plugin to register the implementation +func init() { + api.RegisterMetadataAgent(FakeArtistAgent{}) +} diff --git a/plugins/testdata/fake_init_service/manifest.json b/plugins/testdata/fake_init_service/manifest.json new file mode 100644 index 000000000..ea8c45f58 --- /dev/null +++ b/plugins/testdata/fake_init_service/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_init_service", + "version": "1.0.0", + "capabilities": ["LifecycleManagement"], + "author": "Test Author", + "description": "Test LifecycleManagement Callback", + "website": "https://test.navidrome.org/fake-init-service", + "permissions": {} +} diff --git a/plugins/testdata/fake_init_service/plugin.go b/plugins/testdata/fake_init_service/plugin.go new file mode 100644 index 000000000..5b279b09c --- /dev/null +++ b/plugins/testdata/fake_init_service/plugin.go @@ -0,0 +1,25 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + + "github.com/navidrome/navidrome/plugins/api" +) + +type initServicePlugin struct{} + +func (p *initServicePlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("OnInit called with %v", req) + return &api.InitResponse{}, nil +} + +// Required by Go WASI build +func main() {} + +// Register the LifecycleManagement implementation +func init() { + api.RegisterLifecycleManagement(&initServicePlugin{}) +} diff --git a/plugins/testdata/fake_scrobbler/manifest.json b/plugins/testdata/fake_scrobbler/manifest.json new file mode 100644 index 000000000..6fa41aa31 --- /dev/null +++ b/plugins/testdata/fake_scrobbler/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_scrobbler", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for scrobbler", + "website": "https://test.navidrome.org/fake-scrobbler", + "capabilities": ["Scrobbler"], + "permissions": {} +} diff --git a/plugins/testdata/fake_scrobbler/plugin.go b/plugins/testdata/fake_scrobbler/plugin.go new file mode 100644 index 000000000..5a5c76699 --- /dev/null +++ b/plugins/testdata/fake_scrobbler/plugin.go @@ -0,0 +1,33 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + + "github.com/navidrome/navidrome/plugins/api" +) + +type FakeScrobbler struct{} + +func (FakeScrobbler) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) { + log.Printf("[FakeScrobbler] IsAuthorized called for user: %s (%s)", req.Username, req.UserId) + return &api.ScrobblerIsAuthorizedResponse{Authorized: true}, nil +} + +func (FakeScrobbler) NowPlaying(ctx context.Context, req *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) { + log.Printf("[FakeScrobbler] NowPlaying called for user: %s (%s), track: %s", req.Username, req.UserId, req.Track.Name) + return &api.ScrobblerNowPlayingResponse{}, nil +} + +func (FakeScrobbler) Scrobble(ctx context.Context, req *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) { + log.Printf("[FakeScrobbler] Scrobble called for user: %s (%s), track: %s, timestamp: %d", req.Username, req.UserId, req.Track.Name, req.Timestamp) + return &api.ScrobblerScrobbleResponse{}, nil +} + +func main() {} + +func init() { + api.RegisterScrobbler(FakeScrobbler{}) +} diff --git a/plugins/testdata/multi_plugin/manifest.json b/plugins/testdata/multi_plugin/manifest.json new file mode 100644 index 000000000..dc9e0a9a8 --- /dev/null +++ b/plugins/testdata/multi_plugin/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "multi_plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for multiple services", + "website": "https://test.navidrome.org/multi-plugin", + "capabilities": ["MetadataAgent", "SchedulerCallback", "LifecycleManagement"], + "permissions": { + "scheduler": { + "reason": "For testing scheduled callback functionality" + } + } +} diff --git a/plugins/testdata/multi_plugin/plugin.go b/plugins/testdata/multi_plugin/plugin.go new file mode 100644 index 000000000..3c28bd214 --- /dev/null +++ b/plugins/testdata/multi_plugin/plugin.go @@ -0,0 +1,124 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/scheduler" +) + +// MultiPlugin implements the MetadataAgent interface for testing +type MultiPlugin struct{} + +var ErrNotFound = api.ErrNotFound + +var sched = scheduler.NewSchedulerService() + +// Artist-related methods +func (MultiPlugin) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + if req.Name != "" { + return &api.ArtistMBIDResponse{Mbid: "multi-artist-mbid"}, nil + } + return nil, ErrNotFound +} + +func (MultiPlugin) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + log.Printf("GetArtistURL received: %v", req) + + // Use an ID that could potentially clash with other plugins + // The host will ensure this doesn't conflict by prefixing with plugin name + customId := "artist:" + req.Name + log.Printf("Registering scheduler with custom ID: %s", customId) + + // Use the scheduler service for one-time scheduling + resp, err := sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{ + ScheduleId: customId, + DelaySeconds: 6, + Payload: []byte("test-payload"), + }) + if err != nil { + log.Printf("Error scheduling one-time job: %v", err) + } else { + log.Printf("One-time schedule registered with ID: %s", resp.ScheduleId) + } + + return &api.ArtistURLResponse{Url: "https://multi.example.com/artist"}, nil +} + +func (MultiPlugin) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return &api.ArtistBiographyResponse{Biography: "Multi agent artist bio"}, nil +} + +func (MultiPlugin) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return &api.ArtistSimilarResponse{}, nil +} + +func (MultiPlugin) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return &api.ArtistImageResponse{}, nil +} + +func (MultiPlugin) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return &api.ArtistTopSongsResponse{}, nil +} + +// Album-related methods +func (MultiPlugin) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + if req.Name != "" && req.Artist != "" { + return &api.AlbumInfoResponse{ + Info: &api.AlbumInfo{ + Name: req.Name, + Mbid: "multi-album-mbid", + Description: "Multi agent album description", + Url: "https://multi.example.com/album", + }, + }, nil + } + return nil, ErrNotFound +} + +func (MultiPlugin) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return &api.AlbumImagesResponse{}, nil +} + +// Scheduler callback +func (MultiPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + log.Printf("Scheduler callback received with ID: %s, payload: '%s', isRecurring: %v", + req.ScheduleId, string(req.Payload), req.IsRecurring) + + // Demonstrate how to parse the custom ID format + if strings.HasPrefix(req.ScheduleId, "artist:") { + parts := strings.Split(req.ScheduleId, ":") + if len(parts) == 2 { + artistName := parts[1] + log.Printf("This schedule was for artist: %s", artistName) + } + } + + return &api.SchedulerCallbackResponse{}, nil +} + +func (MultiPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("OnInit called with %v", req) + + // Schedule a recurring every 5 seconds + _, _ = sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + CronExpression: "@every 5s", + Payload: []byte("every 5 seconds"), + }) + + return &api.InitResponse{}, nil +} + +// Required by Go WASI build +func main() {} + +// Register the service implementations +func init() { + api.RegisterLifecycleManagement(MultiPlugin{}) + api.RegisterMetadataAgent(MultiPlugin{}) + api.RegisterSchedulerCallback(MultiPlugin{}) +} diff --git a/plugins/testdata/unauthorized_plugin/manifest.json b/plugins/testdata/unauthorized_plugin/manifest.json new file mode 100644 index 000000000..38a00e0ea --- /dev/null +++ b/plugins/testdata/unauthorized_plugin/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "unauthorized_plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin that tries to access unauthorized services", + "website": "https://test.navidrome.org/unauthorized-plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} +} diff --git a/plugins/testdata/unauthorized_plugin/plugin.go b/plugins/testdata/unauthorized_plugin/plugin.go new file mode 100644 index 000000000..07c3e0f6b --- /dev/null +++ b/plugins/testdata/unauthorized_plugin/plugin.go @@ -0,0 +1,78 @@ +//go:build wasip1 + +package main + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/http" +) + +type UnauthorizedPlugin struct{} + +var ErrNotFound = api.ErrNotFound + +func (UnauthorizedPlugin) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + // This plugin attempts to make an HTTP call without having HTTP permission + // This should fail since the plugin has no permissions in its manifest + httpClient := http.NewHttpService() + + request := &http.HttpRequest{ + Url: "https://example.com/test", + Headers: map[string]string{ + "Accept": "application/json", + }, + TimeoutMs: 5000, + } + + _, err := httpClient.Get(ctx, request) + if err != nil { + // Expected to fail due to missing permission + return nil, err + } + + return &api.AlbumInfoResponse{ + Info: &api.AlbumInfo{ + Name: req.Name, + Mbid: "unauthorized-test", + Description: "This should not work", + Url: "https://example.com/unauthorized", + }, + }, nil +} + +func (UnauthorizedPlugin) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, api.ErrNotImplemented +} + +func main() {} + +// Register the plugin implementation +func init() { + api.RegisterMetadataAgent(UnauthorizedPlugin{}) +} diff --git a/plugins/wasm_base_plugin.go b/plugins/wasm_base_plugin.go new file mode 100644 index 000000000..bc1f1d2f5 --- /dev/null +++ b/plugins/wasm_base_plugin.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" +) + +// newWasmBasePlugin creates a new instance of wasmBasePlugin with the required parameters. +func newWasmBasePlugin[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *wasmBasePlugin[S, P] { + return &wasmBasePlugin[S, P]{ + wasmPath: wasmPath, + id: id, + capability: capability, + loader: loader, + loadFunc: loadFunc, + metrics: m, + } +} + +// LoaderFunc is a generic function type that loads a plugin instance. +type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error) + +// wasmBasePlugin is a generic base implementation for WASM plugins. +// S is the service interface type and P is the plugin loader type. +type wasmBasePlugin[S any, P any] struct { + wasmPath string + id string + capability string + loader P + loadFunc loaderFunc[S, P] + metrics metrics.Metrics +} + +func (w *wasmBasePlugin[S, P]) PluginID() string { + return w.id +} + +func (w *wasmBasePlugin[S, P]) Instantiate(ctx context.Context) (any, func(), error) { + return w.getInstance(ctx, "<none>") +} + +func (w *wasmBasePlugin[S, P]) serviceName() string { + return w.id + "_" + w.capability +} + +func (w *wasmBasePlugin[S, P]) getMetrics() metrics.Metrics { + return w.metrics +} + +// getInstance loads a new plugin instance and returns a cleanup function. +func (w *wasmBasePlugin[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) { + start := time.Now() + // Add context metadata for tracing + ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName) + + inst, err := w.loadFunc(ctx, w.loader, w.wasmPath) + if err != nil { + var zero S + return zero, func() {}, fmt.Errorf("wasmBasePlugin: failed to load instance for %s: %w", w.serviceName(), err) + } + // Add context metadata for tracing + ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst)) + log.Trace(ctx, "wasmBasePlugin: loaded instance", "elapsed", time.Since(start)) + return inst, func() { + log.Trace(ctx, "wasmBasePlugin: finished using instance", "elapsed", time.Since(start)) + if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok { + _ = closer.Close(ctx) + } + }, nil +} + +type wasmPlugin[S any] interface { + PluginID() string + getInstance(ctx context.Context, methodName string) (S, func(), error) + getMetrics() metrics.Metrics +} + +type errorMapper interface { + mapError(err error) error +} + +func callMethod[S any, R any](ctx context.Context, w wasmPlugin[S], methodName string, fn func(inst S) (R, error)) (R, error) { + // Add a unique call ID to the context for tracing + ctx = log.NewContext(ctx, "callID", id.NewRandom()) + + inst, done, err := w.getInstance(ctx, methodName) + var r R + if err != nil { + return r, err + } + start := time.Now() + defer done() + r, err = fn(inst) + elapsed := time.Since(start) + + if em, ok := any(w).(errorMapper); ok { + mappedErr := em.mapError(err) + + if !errors.Is(mappedErr, agents.ErrNotFound) { + id := w.PluginID() + isOk := mappedErr == nil + metrics := w.getMetrics() + if metrics != nil { + metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds()) + } + log.Trace(ctx, "callMethod", "plugin", id, "method", methodName, "ok", isOk, elapsed) + } + + return r, mappedErr + } + return r, err +} diff --git a/plugins/wasm_base_plugin_test.go b/plugins/wasm_base_plugin_test.go new file mode 100644 index 000000000..6d6421598 --- /dev/null +++ b/plugins/wasm_base_plugin_test.go @@ -0,0 +1,32 @@ +package plugins + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type nilInstance struct{} + +var _ = Describe("wasmBasePlugin", func() { + var ctx = context.Background() + + It("should load instance using loadFunc", func() { + called := false + plugin := &wasmBasePlugin[*nilInstance, any]{ + wasmPath: "", + id: "test", + capability: "test", + loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) { + called = true + return &nilInstance{}, nil + }, + } + inst, done, err := plugin.getInstance(ctx, "test") + defer done() + Expect(err).To(BeNil()) + Expect(inst).ToNot(BeNil()) + Expect(called).To(BeTrue()) + }) +}) diff --git a/plugins/wasm_instance_pool.go b/plugins/wasm_instance_pool.go new file mode 100644 index 000000000..5ea1a82a6 --- /dev/null +++ b/plugins/wasm_instance_pool.go @@ -0,0 +1,223 @@ +package plugins + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/navidrome/navidrome/log" +) + +// wasmInstancePool is a generic pool using channels for simplicity and Go idioms +type wasmInstancePool[T any] struct { + name string + new func(ctx context.Context) (T, error) + poolSize int + getTimeout time.Duration + ttl time.Duration + + mu sync.RWMutex + instances chan poolItem[T] + semaphore chan struct{} + closing chan struct{} + closed bool +} + +type poolItem[T any] struct { + value T + created time.Time +} + +func newWasmInstancePool[T any](name string, poolSize int, maxConcurrentInstances int, getTimeout time.Duration, ttl time.Duration, newFn func(ctx context.Context) (T, error)) *wasmInstancePool[T] { + p := &wasmInstancePool[T]{ + name: name, + new: newFn, + poolSize: poolSize, + getTimeout: getTimeout, + ttl: ttl, + instances: make(chan poolItem[T], poolSize), + semaphore: make(chan struct{}, maxConcurrentInstances), + closing: make(chan struct{}), + } + + // Fill semaphore to allow maxConcurrentInstances + for i := 0; i < maxConcurrentInstances; i++ { + p.semaphore <- struct{}{} + } + + log.Debug(context.Background(), "wasmInstancePool: created new pool", "pool", p.name, "poolSize", p.poolSize, "maxConcurrentInstances", maxConcurrentInstances, "getTimeout", p.getTimeout, "ttl", p.ttl) + go p.cleanupLoop() + return p +} + +func getInstanceID(inst any) string { + return fmt.Sprintf("%p", inst) //nolint:govet +} + +func (p *wasmInstancePool[T]) Get(ctx context.Context) (T, error) { + // First acquire a semaphore slot (concurrent limit) + select { + case <-p.semaphore: + // Got slot, continue + case <-ctx.Done(): + var zero T + return zero, ctx.Err() + case <-time.After(p.getTimeout): + var zero T + return zero, fmt.Errorf("timeout waiting for available instance after %v", p.getTimeout) + case <-p.closing: + var zero T + return zero, fmt.Errorf("pool is closing") + } + + // Try to get from pool first + p.mu.RLock() + instances := p.instances + p.mu.RUnlock() + + select { + case item := <-instances: + log.Trace(ctx, "wasmInstancePool: got instance from pool", "pool", p.name, "instanceID", getInstanceID(item.value)) + return item.value, nil + default: + // Pool empty, create new instance + instance, err := p.new(ctx) + if err != nil { + // Failed to create, return semaphore slot + log.Trace(ctx, "wasmInstancePool: failed to create new instance", "pool", p.name, err) + p.semaphore <- struct{}{} + var zero T + return zero, err + } + log.Trace(ctx, "wasmInstancePool: new instance created", "pool", p.name, "instanceID", getInstanceID(instance)) + return instance, nil + } +} + +func (p *wasmInstancePool[T]) Put(ctx context.Context, v T) { + p.mu.RLock() + instances := p.instances + closed := p.closed + p.mu.RUnlock() + + if closed { + log.Trace(ctx, "wasmInstancePool: pool closed, closing instance", "pool", p.name, "instanceID", getInstanceID(v)) + p.closeItem(ctx, v) + // Return semaphore slot only if this instance came from Get() + select { + case p.semaphore <- struct{}{}: + case <-p.closing: + default: + // Semaphore full, this instance didn't come from Get() + } + return + } + + // Try to return to pool + item := poolItem[T]{value: v, created: time.Now()} + select { + case instances <- item: + log.Trace(ctx, "wasmInstancePool: returned instance to pool", "pool", p.name, "instanceID", getInstanceID(v)) + default: + // Pool full, close instance + log.Trace(ctx, "wasmInstancePool: pool full, closing instance", "pool", p.name, "instanceID", getInstanceID(v)) + p.closeItem(ctx, v) + } + + // Return semaphore slot only if this instance came from Get() + // If semaphore is full, this instance didn't come from Get(), so don't block + select { + case p.semaphore <- struct{}{}: + // Successfully returned token + case <-p.closing: + // Pool closing, don't block + default: + // Semaphore full, this instance didn't come from Get() + } +} + +func (p *wasmInstancePool[T]) Close(ctx context.Context) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return + } + p.closed = true + close(p.closing) + instances := p.instances + p.mu.Unlock() + + log.Trace(ctx, "wasmInstancePool: closing pool and all instances", "pool", p.name) + + // Drain and close all instances + for { + select { + case item := <-instances: + p.closeItem(ctx, item.value) + default: + return + } + } +} + +func (p *wasmInstancePool[T]) cleanupLoop() { + ticker := time.NewTicker(p.ttl / 3) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.cleanupExpired() + case <-p.closing: + return + } + } +} + +func (p *wasmInstancePool[T]) cleanupExpired() { + ctx := context.Background() + now := time.Now() + + // Create new channel with same capacity + newInstances := make(chan poolItem[T], p.poolSize) + + // Atomically swap channels + p.mu.Lock() + oldInstances := p.instances + p.instances = newInstances + p.mu.Unlock() + + // Drain old channel, keeping fresh items + var expiredCount int + for { + select { + case item := <-oldInstances: + if now.Sub(item.created) <= p.ttl { + // Item is still fresh, move to new channel + select { + case newInstances <- item: + // Successfully moved + default: + // New channel full, close excess item + p.closeItem(ctx, item.value) + } + } else { + // Item expired, close it + expiredCount++ + p.closeItem(ctx, item.value) + } + default: + // Old channel drained + if expiredCount > 0 { + log.Trace(ctx, "wasmInstancePool: cleaned up expired instances", "pool", p.name, "expiredCount", expiredCount) + } + return + } + } +} + +func (p *wasmInstancePool[T]) closeItem(ctx context.Context, v T) { + if closer, ok := any(v).(interface{ Close(context.Context) error }); ok { + _ = closer.Close(ctx) + } +} diff --git a/plugins/wasm_instance_pool_test.go b/plugins/wasm_instance_pool_test.go new file mode 100644 index 000000000..141210473 --- /dev/null +++ b/plugins/wasm_instance_pool_test.go @@ -0,0 +1,193 @@ +package plugins + +import ( + "context" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type testInstance struct { + closed atomic.Bool +} + +func (t *testInstance) Close(ctx context.Context) error { + t.closed.Store(true) + return nil +} + +var _ = Describe("wasmInstancePool", func() { + var ( + ctx = context.Background() + ) + + It("should Get and Put instances", func() { + pool := newWasmInstancePool[*testInstance]("test", 2, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst, err := pool.Get(ctx) + Expect(err).To(BeNil()) + Expect(inst).ToNot(BeNil()) + pool.Put(ctx, inst) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + Expect(inst2).To(Equal(inst)) + pool.Close(ctx) + }) + + It("should not exceed max instances", func() { + pool := newWasmInstancePool[*testInstance]("test", 1, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2 := &testInstance{} + pool.Put(ctx, inst1) + pool.Put(ctx, inst2) // should close inst2 + Expect(inst2.closed.Load()).To(BeTrue()) + pool.Close(ctx) + }) + + It("should expire and close instances after TTL", func() { + pool := newWasmInstancePool[*testInstance]("test", 2, 10, 5*time.Second, 100*time.Millisecond, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst, err := pool.Get(ctx) + Expect(err).To(BeNil()) + pool.Put(ctx, inst) + // Wait for TTL cleanup + time.Sleep(300 * time.Millisecond) + Expect(inst.closed.Load()).To(BeTrue()) + pool.Close(ctx) + }) + + It("should close all on pool Close", func() { + pool := newWasmInstancePool[*testInstance]("test", 2, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + pool.Put(ctx, inst1) + pool.Put(ctx, inst2) + pool.Close(ctx) + Expect(inst1.closed.Load()).To(BeTrue()) + Expect(inst2.closed.Load()).To(BeTrue()) + }) + + It("should be safe for concurrent Get/Put", func() { + pool := newWasmInstancePool[*testInstance]("test", 4, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + done := make(chan struct{}) + for i := 0; i < 8; i++ { + go func() { + inst, err := pool.Get(ctx) + Expect(err).To(BeNil()) + pool.Put(ctx, inst) + done <- struct{}{} + }() + } + for i := 0; i < 8; i++ { + <-done + } + pool.Close(ctx) + }) + + It("should enforce max concurrent instances limit", func() { + callCount := atomic.Int32{} + pool := newWasmInstancePool[*testInstance]("test", 2, 3, 100*time.Millisecond, time.Second, func(ctx context.Context) (*testInstance, error) { + callCount.Add(1) + return &testInstance{}, nil + }) + + // Get 3 instances (should hit the limit) + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst3, err := pool.Get(ctx) + Expect(err).To(BeNil()) + + // Should have created exactly 3 instances at this point + Expect(callCount.Load()).To(Equal(int32(3))) + + // Fourth call should timeout without creating a new instance + start := time.Now() + _, err = pool.Get(ctx) + duration := time.Since(start) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout waiting for available instance")) + Expect(duration).To(BeNumerically(">=", 100*time.Millisecond)) + Expect(duration).To(BeNumerically("<", 200*time.Millisecond)) + + // Still should have only 3 instances (timeout didn't create new one) + Expect(callCount.Load()).To(Equal(int32(3))) + + // Return one instance and try again - should succeed by reusing returned instance + pool.Put(ctx, inst1) + inst4, err := pool.Get(ctx) + Expect(err).To(BeNil()) + Expect(inst4).To(Equal(inst1)) // Should be the same instance we returned + + // Still should have only 3 instances total (reused inst1) + Expect(callCount.Load()).To(Equal(int32(3))) + + pool.Put(ctx, inst2) + pool.Put(ctx, inst3) + pool.Put(ctx, inst4) + pool.Close(ctx) + }) + + It("should handle concurrent waiters properly", func() { + pool := newWasmInstancePool[*testInstance]("test", 1, 2, time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + + // Fill up the concurrent slots + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + + // Start multiple waiters + waiterResults := make(chan error, 3) + for i := 0; i < 3; i++ { + go func() { + _, err := pool.Get(ctx) + waiterResults <- err + }() + } + + // Wait a bit to ensure waiters are queued + time.Sleep(50 * time.Millisecond) + + // Return instances one by one + pool.Put(ctx, inst1) + pool.Put(ctx, inst2) + + // Two waiters should succeed, one should timeout + successCount := 0 + timeoutCount := 0 + for i := 0; i < 3; i++ { + select { + case err := <-waiterResults: + if err == nil { + successCount++ + } else { + timeoutCount++ + } + case <-time.After(2 * time.Second): + Fail("Test timed out waiting for waiter results") + } + } + + Expect(successCount).To(Equal(2)) + Expect(timeoutCount).To(Equal(1)) + + pool.Close(ctx) + }) +}) diff --git a/reflex.conf b/reflex.conf index 2eb4d131c..4cd64baf9 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -- go run -race -tags netgo . +-s -r "(\.go$$|\.cpp$$|\.h$$|\.wasm$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -- go run -race -tags netgo . diff --git a/release/goreleaser.yml b/release/goreleaser.yml index 1a420c927..f71c38f31 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -25,7 +25,8 @@ builds: archives: - format_overrides: - goos: windows - format: zip + formats: + - zip name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' checksum: diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 6ffb31165..090360c81 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -2,7 +2,7 @@ "languageName": "Deutsch", "resources": { "song": { - "name": "Song |||| Songs", + "name": "Titel |||| Titel", "fields": { "albumArtist": "Albuminterpret", "duration": "Dauer", @@ -32,16 +32,20 @@ "participants": "Weitere Beteiligte", "tags": "Weitere Tags", "mappedTags": "Gemappte Tags", - "rawTags": "Tag Rohdaten" + "rawTags": "Tag Rohdaten", + "bitDepth": "Bittiefe", + "sampleRate": "Samplerate", + "missing": "Fehlend" }, "actions": { "addToQueue": "Später abspielen", "playNow": "Jetzt abspielen", - "addToPlaylist": "Zur Playlist hinzufügen", + "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", "shuffleAll": "Zufallswiedergabe", "download": "Herunterladen", "playNext": "Als nächstes abspielen", - "info": "Mehr Informationen" + "info": "Mehr Informationen", + "showInPlaylist": "In Wiedergabeliste anzeigen" } }, "album": { @@ -70,14 +74,16 @@ "releaseType": "Typ", "grouping": "Gruppierung", "media": "Medium", - "mood": "Stimmung" + "mood": "Stimmung", + "date": "Aufnahmedatum", + "missing": "Fehlend" }, "actions": { "playAll": "Abspielen", "playNext": "Als nächstes abspielen", "addToQueue": "Später abspielen", "shuffle": "Zufallswiedergabe", - "addToPlaylist": "Zur Playlist hinzufügen", + "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", "download": "Herunterladen", "info": "Mehr Informationen", "share": "Freigabe erstellen" @@ -102,7 +108,8 @@ "rating": "Bewertung", "genre": "Genre", "size": "Größe", - "role": "Rolle" + "role": "Rolle", + "missing": "Fehlend" }, "roles": { "albumartist": "Albuminterpret |||| Albuminterpreten", @@ -117,7 +124,13 @@ "mixer": "Mixer |||| Mixer", "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "ausübender Künstler |||| ausübende Künstler" + "performer": "ausübender Künstler |||| ausübende Künstler", + "maincredit": "Albuminterpret oder Interpret |||| Albuminterpreten oder Interpreten" + }, + "actions": { + "shuffle": "Zufallswiedergabe", + "radio": "Radio", + "topSongs": "Beliebteste Titel" } }, "user": { @@ -172,7 +185,7 @@ } }, "playlist": { - "name": "Playlist |||| Playlists", + "name": "Wiedergabeliste |||| Wiedergabelisten", "fields": { "name": "Name", "duration": "Dauer", @@ -186,15 +199,22 @@ "path": "Importieren aus" }, "actions": { - "selectPlaylist": "Titel zur Playlist hinzufügen", + "selectPlaylist": "Wiedergabeliste auswählen:", "addNewPlaylist": "\"%{name}\" erstellen", "export": "Exportieren", "makePublic": "Öffentlich machen", - "makePrivate": "Privat stellen" + "makePrivate": "Privat stellen", + "saveQueue": "Warteschlange in Wiedergabeliste speichern", + "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", + "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", + "removeFromSelection": "Von Auswahl entfernen", + "removeSymbol": "×" }, "message": { "duplicate_song": "Duplikate hinzufügen", - "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?" + "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?", + "noPlaylistsFound": "Keine Wiedergabeliste gefunden", + "noPlaylists": "Keine Wiedergabelisten vorhanden" } }, "radio": { @@ -235,11 +255,13 @@ "updatedAt": "Fehlt seit" }, "actions": { - "remove": "Entfernen" + "remove": "Entfernen", + "remove_all": "alle entfernen" }, "notifications": { "removed": "Fehlende Datei(en) entfernt" - } + }, + "empty": "keine fehlenden Dateien" } }, "ra": { @@ -391,10 +413,10 @@ "note": "HINWEIS", "transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.", "transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.", - "songsAddedToPlaylist": "Einen Titel zur Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt", - "noPlaylistsAvailable": "Keine Playlist verfügbar", + "songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt", + "noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar", "delete_user_title": "Benutzer '%{name}' löschen", - "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?", + "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?", "notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert", "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", @@ -419,7 +441,11 @@ "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter", "remove_missing_title": "Fehlende Dateien entfernen", - "remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht." + "remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", + "remove_all_missing_title": "Alle fehlenden Dateien entfernen", + "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", + "noSimilarSongsFound": "Keine ähnlichen Titel gefunden", + "noTopSongsFound": "Keine beliebten Titel gefunden" }, "menu": { "library": "Bibliothek", @@ -447,11 +473,11 @@ }, "albumList": "Alben", "about": "Über", - "playlists": "Playlisten", - "sharedPlaylists": "Geteilte Playlisten" + "playlists": "Wiedergabelisten", + "sharedPlaylists": "Geteilte Wiedergabelisten" }, "player": { - "playListsText": "Wiedergabeliste abspielen", + "playListsText": "Warteschlange abspielen", "openText": "Öffnen", "closeText": "Schließen", "notContentText": "Keine Musik", @@ -485,6 +511,21 @@ "disabled": "Deaktiviert", "waiting": "Warten" } + }, + "tabs": { + "about": "Über", + "config": "Konfiguration" + }, + "config": { + "configName": "Einstellung", + "environmentVariable": "Umbegungsvariable", + "currentValue": "Wert", + "configurationFile": "Konfigurationsdatei", + "exportToml": "Konfiguration exportieren (TOML)", + "exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert", + "exportFailed": "Fehler beim Kopieren der Konfiguration", + "devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)", + "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden" } }, "activity": { @@ -493,7 +534,10 @@ "quickScan": "Schneller Scan", "fullScan": "Kompletter Scan", "serverUptime": "Server-Betriebszeit", - "serverDown": "OFFLINE" + "serverDown": "OFFLINE", + "scanType": "Typ", + "status": "Scan Fehler", + "elapsedTime": "Laufzeit" }, "help": { "title": "Navidrome Hotkeys", @@ -508,5 +552,10 @@ "toggle_love": "Titel zu Favoriten hinzufügen", "current_song": "Aktuellen Titel Anzeigen" } + }, + "nowPlaying": { + "title": "Aktuelle Wiedergabe", + "empty": "Keine Wiedergabe", + "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } } \ No newline at end of file diff --git a/resources/i18n/el.json b/resources/i18n/el.json index d574821d4..0e8b5a9e5 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -33,7 +33,9 @@ "tags": "Πρόσθετες Ετικέτες", "mappedTags": "Χαρτογραφημένες ετικέτες", "rawTags": "Ακατέργαστες ετικέτες", - "bitDepth": "Λίγο βάθος" + "bitDepth": "Λίγο βάθος", + "sampleRate": "Ποσοστό δειγματοληψίας", + "missing": "Απών" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -42,7 +44,8 @@ "shuffleAll": "Ανακατεμα ολων", "download": "Ληψη", "playNext": "Επόμενη Αναπαραγωγή", - "info": "Εμφάνιση Πληροφοριών" + "info": "Εμφάνιση Πληροφοριών", + "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής" } }, "album": { @@ -72,7 +75,8 @@ "grouping": "Ομαδοποίηση", "media": "Μέσα", "mood": "Διάθεση", - "date": "Ημερομηνία Ηχογράφησης" + "date": "Ημερομηνία Ηχογράφησης", + "missing": "Απών" }, "actions": { "playAll": "Αναπαραγωγή", @@ -104,7 +108,8 @@ "rating": "Βαθμολογια", "genre": "Είδος", "size": "Μέγεθος", - "role": "Ρόλος" + "role": "Ρόλος", + "missing": "Απών" }, "roles": { "albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ", @@ -119,7 +124,13 @@ "mixer": "Μίξερ |||| Μίξερ", "remixer": "Ρεμίξερ |||| Ρεμίξερ", "djmixer": "Dj Μίξερ |||| Dj Μίξερ", - "performer": "Εκτελεστής |||| Ερμηνευτές" + "performer": "Εκτελεστής |||| Ερμηνευτές", + "maincredit": "Καλλιτέχνης Άλμπουμ ή Καλλιτέχνης |||| Καλλιτέχνες Άλμπουμ ή Καλλιτέχνες" + }, + "actions": { + "shuffle": "Ανάμιξη", + "radio": "Ραδιόφωνο", + "topSongs": "Κορυφαία τραγούδια" } }, "user": { @@ -132,7 +143,7 @@ "name": "Όνομα", "password": "Κωδικός Πρόσβασης", "createdAt": "Δημιουργήθηκε στις", - "changePassword": "Αλλαγή Κωδικού Πρόσβασης;", + "changePassword": "Αλλαγή Κωδικού Πρόσβασης?", "currentPassword": "Υπάρχων Κωδικός Πρόσβασης", "newPassword": "Νέος Κωδικός Πρόσβασης", "token": "Token", @@ -192,11 +203,18 @@ "addNewPlaylist": "Δημιουργία \"%{name}\"", "export": "Εξαγωγη", "makePublic": "Να γίνει δημόσιο", - "makePrivate": "Να γίνει ιδιωτικό" + "makePrivate": "Να γίνει ιδιωτικό", + "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", + "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", + "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", + "removeFromSelection": "Αφαίρεση από την επιλογή", + "removeSymbol": "x" }, "message": { "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", - "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;" + "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?", + "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής", + "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής" } }, "radio": { @@ -237,7 +255,8 @@ "updatedAt": "Εξαφανίστηκε" }, "actions": { - "remove": "Αφαίρεση" + "remove": "Αφαίρεση", + "remove_all": "Αφαίρεση όλων" }, "notifications": { "removed": "Λείπει αρχείο(α) αφαιρέθηκε" @@ -305,7 +324,7 @@ "skip": "Παράβλεψη", "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Κοινοποίηση", - "download": "Λήψη " + "download": "Λήψη" }, "boolean": { "true": "Ναι", @@ -344,10 +363,10 @@ }, "message": { "about": "Σχετικά", - "are_you_sure": "Είστε σίγουροι;", - "bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};", + "are_you_sure": "Είστε σίγουροι?", + "bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}? |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count}?", "bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}", - "delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;", + "delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο?", "delete_title": "Διαγραφή του %{name} #%{id}", "details": "Λεπτομέρειες", "error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.", @@ -356,12 +375,12 @@ "no": "Όχι", "not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.", "yes": "Ναι", - "unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;" + "unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε?" }, "navigation": { "no_results": "Δεν βρέθηκαν αποτελέσματα", "no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.", - "page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων", + "page_out_of_boundaries": "Η σελίδα %{page} είναι εκτός ορίων", "page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας", "page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}", @@ -397,7 +416,7 @@ "songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής", "noPlaylistsAvailable": "Κανένα διαθέσιμο", "delete_user_title": "Διαγραφή του χρήστη '%{name}'", - "delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);", + "delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων)?", "notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας", "notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https", "lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε", @@ -422,7 +441,11 @@ "downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})", "shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter", "remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν", - "remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους." + "remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους.", + "remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν", + "remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", + "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", + "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια" }, "menu": { "library": "Βιβλιοθήκη", @@ -488,6 +511,21 @@ "disabled": "Απενεργοποιημένο", "waiting": "Αναμονή" } + }, + "tabs": { + "about": "Σχετικά", + "config": "Διαμόρφωση" + }, + "config": { + "configName": "Όνομα διαμόρφωσης", + "environmentVariable": "Μεταβλητή περιβάλλοντος", + "currentValue": "Τρέχουσα Αξία", + "configurationFile": "Αρχείο διαμόρφωσης", + "exportToml": "Ρύθμιση παραμέτρων εξαγωγής (TOML)", + "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML", + "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε", + "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)", + "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις" } }, "activity": { @@ -496,7 +534,10 @@ "quickScan": "Γρήγορη Σάρωση", "fullScan": "Πλήρης Σάρωση", "serverUptime": "Λειτουργία Διακομιστή", - "serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ" + "serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ", + "scanType": "Τύπος", + "status": "Σφάλμα σάρωσης", + "elapsedTime": "Χρόνος που πέρασε" }, "help": { "title": "Συντομεύσεις του Navidrome", @@ -511,5 +552,10 @@ "toggle_love": "Προσθήκη αυτού του κομματιού στα αγαπημένα", "current_song": "Μεταβείτε στο Τρέχον τραγούδι" } + }, + "nowPlaying": { + "title": "Αναπαραγωγή τώρα", + "empty": "Δεν παίζει τίποτα", + "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } } \ No newline at end of file diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json index 570943a1d..bdf143969 100644 --- a/resources/i18n/eo.json +++ b/resources/i18n/eo.json @@ -24,16 +24,18 @@ "rating": "Takso", "quality": "Kvalito", "bpm": "Pulsrapideco", - "playDate": "", - "channels": "", - "createdAt": "", + "playDate": "Laste Ludita", + "channels": "Kanaloj", + "createdAt": "Dato de aligo", "grouping": "", - "mood": "", + "mood": "Humoro", "participants": "", - "tags": "", - "mappedTags": "", - "rawTags": "", - "bitDepth": "" + "tags": "Aldonaj Etikedoj", + "mappedTags": "Mapigitaj etikedoj", + "rawTags": "Krudaj etikedoj", + "bitDepth": "", + "sampleRate": "", + "missing": "" }, "actions": { "addToQueue": "Ludi Poste", @@ -42,7 +44,7 @@ "shuffleAll": "Miksu Ĉiujn", "download": "Elŝuti", "playNext": "Ludu Poste", - "info": "" + "info": "Akiri Informon" } }, "album": { @@ -60,19 +62,20 @@ "updatedAt": "Ĝisdatigita je :", "comment": "Komento", "rating": "Takso", - "createdAt": "", - "size": "", - "originalDate": "", - "releaseDate": "", - "releases": "", - "released": "", + "createdAt": "Dato aldonita", + "size": "Grando", + "originalDate": "Originala", + "releaseDate": "Publikiĝis", + "releases": "Publikiĝo |||| Publikiĝoj", + "released": "Publikiĝis", "recordLabel": "", "catalogNum": "", - "releaseType": "", + "releaseType": "Tipo", "grouping": "", "media": "", - "mood": "", - "date": "" + "mood": "Humoro", + "date": "", + "missing": "" }, "actions": { "playAll": "Ludi", @@ -81,43 +84,44 @@ "shuffle": "Miksi", "addToPlaylist": "Aldoni al la Ludlisto", "download": "Elŝuti", - "info": "", - "share": "" + "info": "Akiri Informon", + "share": "Diskonigi" }, "lists": { "all": "Ĉiuj", - "random": "Hazarda", - "recentlyAdded": "Lastatempe Aldonita", - "recentlyPlayed": "Lastatempe Ludita", + "random": "Hazardaj", + "recentlyAdded": "Lastatempe Aldonitaj", + "recentlyPlayed": "Lastatempe Luditaj", "mostPlayed": "Plej Luditaj", - "starred": "Stelplena", - "topRated": "Plej Alte Taksite" + "starred": "Stelplenaj", + "topRated": "Plej Alte Taksitaj" } }, "artist": { "name": "Artisto |||| Artistoj", "fields": { "name": "Nomo", - "albumCount": "Nombro da albumoj", - "songCount": "Kanto kalkula", - "playCount": "Teatraĵoj", + "albumCount": "Kvanto da Albumoj", + "songCount": "Kanta Kalkulo", + "playCount": "Ludoj", "rating": "Takso", - "genre": "", - "size": "", - "role": "" + "genre": "Ĝenro", + "size": "Grando", + "role": "", + "missing": "" }, "roles": { - "albumartist": "", - "artist": "", - "composer": "", - "conductor": "", - "lyricist": "", - "arranger": "", + "albumartist": "Albuma Artisto |||| Albumaj Artistoj", + "artist": "Artisto |||| Artistoj", + "composer": "Komponisto |||| Komponistoj", + "conductor": "Dirigento |||| Dirigentoj", + "lyricist": "Kantoteksisto |||| Kantotekstistoj", + "arranger": "Aranĝisto |||| Aranĝistoj", "producer": "", "director": "", "engineer": "", - "mixer": "", - "remixer": "", + "mixer": "Miksisto |||| Miksistoj", + "remixer": "Remiksisto |||| Remiksistoj", "djmixer": "", "performer": "" } @@ -135,8 +139,8 @@ "changePassword": "Ĉu Ŝanĝi Pasvorton?", "currentPassword": "Nuna Pasvorto", "newPassword": "Nova Pasvorto", - "token": "", - "lastAccessAt": "" + "token": "Ĵetono", + "lastAccessAt": "Lasta Atingo" }, "helperTexts": { "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto" @@ -147,8 +151,8 @@ "deleted": "Uzanto forigita" }, "message": { - "listenBrainzToken": "", - "clickHereForToken": "" + "listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.", + "clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon" } }, "player": { @@ -161,7 +165,7 @@ "userName": "Uzantnomo", "lastSeen": "Laste Vidita Je", "reportRealPath": "Raporti vera pado", - "scrobbleEnabled": "" + "scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj" } }, "transcoding": { @@ -191,8 +195,9 @@ "selectPlaylist": "Elektu ludliston :", "addNewPlaylist": "Krei \"%{name}\"", "export": "Eksporti", - "makePublic": "", - "makePrivate": "" + "makePublic": "Publikigi", + "makePrivate": "Malpublikigi", + "saveQueue": "" }, "message": { "duplicate_song": "Aldoni duobligitajn kantojn", @@ -200,33 +205,33 @@ } }, "radio": { - "name": "", + "name": "Radio |||| Radioj", "fields": { - "name": "", - "streamUrl": "", - "homePageUrl": "", - "updatedAt": "", - "createdAt": "" + "name": "Nomo", + "streamUrl": "Flua Ligilo", + "homePageUrl": "Hejmpaĝa Ligilo", + "updatedAt": "Ĝisdatiĝis je", + "createdAt": "Kreiĝis je" }, "actions": { - "playNow": "" + "playNow": "Ludi Nun" } }, "share": { - "name": "", + "name": "Diskonigo |||| Diskonigoj", "fields": { - "username": "", - "url": "", - "description": "", - "contents": "", - "expiresAt": "", - "lastVisitedAt": "", - "visitCount": "", - "format": "", - "maxBitRate": "", - "updatedAt": "", - "createdAt": "", - "downloadable": "" + "username": "Diskonigite De", + "url": "Ligilo", + "description": "Priskribo", + "contents": "Enhavo", + "expiresAt": "Senvalidiĝas", + "lastVisitedAt": "Laste Vizitita", + "visitCount": "Vizitoj", + "format": "Formato", + "maxBitRate": "Maks. Bitrapido", + "updatedAt": "Ĝisdatiĝis je", + "createdAt": "Fariĝis je", + "downloadable": "Ĉu Ebligi Elŝutojn?" } }, "missing": { @@ -237,7 +242,8 @@ "updatedAt": "" }, "actions": { - "remove": "" + "remove": "", + "remove_all": "" }, "notifications": { "removed": "" @@ -258,7 +264,7 @@ "sign_in": "Ensaluti", "sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi", "logout": "Elsaluti", - "insightsCollectionNote": "" + "insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas" }, "validation": { "invalidChars": "Bonvolu uzi nur literojn kaj ciferojn", @@ -273,7 +279,7 @@ "oneOf": "Devas esti unu el: %{options}", "regex": "Devas kongrui kun specifa formato (regexp): %{pattern}", "unique": "Devas esti unika", - "url": "" + "url": "Devas esti valida ligilo" }, "action": { "add_filter": "Aldoni filtrilon", @@ -303,9 +309,9 @@ "close_menu": "Fermu menuon", "unselect": "Malelekti", "skip": "Pasigi", - "bulk_actions_mobile": "", - "share": "", - "download": "" + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Diskonigi", + "download": "Elŝuti" }, "boolean": { "true": "Jes", @@ -381,13 +387,13 @@ "i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo", "canceled": "Ago nuligita", "logged_out": "Via seanco finiĝis, bonvolu rekonekti.", - "new_version": "" + "new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron." }, "toggleFieldsMenu": { - "columnsToDisplay": "", + "columnsToDisplay": "Kolumnoj Por Montri", "layout": "Aranĝo", "grid": "Krado", - "table": "" + "table": "Tabelo" } }, "message": { @@ -400,29 +406,31 @@ "delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?", "notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo", "notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https", - "lastfmLinkSuccess": "", - "lastfmLinkFailure": "", - "lastfmUnlinkSuccess": "", - "lastfmUnlinkFailure": "", + "lastfmLinkSuccess": "Last.fm sukcese ligiĝis kaj scrobbling ebliĝis", + "lastfmLinkFailure": "Last.fm ne povis ligiĝi", + "lastfmUnlinkSuccess": "Last.fm malligiĝis kaj scrobbling malebliĝis", + "lastfmUnlinkFailure": "Last.fm ne povis malligiĝi", "openIn": { - "lastfm": "", - "musicbrainz": "" + "lastfm": "Malfermi en Last.fm", + "musicbrainz": "Malfermi en MusicBrainz" }, - "lastfmLink": "", - "listenBrainzLinkSuccess": "", - "listenBrainzLinkFailure": "", - "listenBrainzUnlinkSuccess": "", - "listenBrainzUnlinkFailure": "", - "downloadOriginalFormat": "", - "shareOriginalFormat": "", - "shareDialogTitle": "", - "shareBatchDialogTitle": "", - "shareSuccess": "", - "shareFailure": "", - "downloadDialogTitle": "", - "shareCopyToClipboard": "", + "lastfmLink": "Legi Pli...", + "listenBrainzLinkSuccess": "ListenBrainz sukcese ligiĝis kaj scrobbling ebliĝis kiel uzanto: %{user}", + "listenBrainzLinkFailure": "ListenBrainz ne povis ligiĝi: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz malligiĝis kaj scrobbling malebliĝis", + "listenBrainzUnlinkFailure": "ListenBrainz ne povis malligiĝi", + "downloadOriginalFormat": "Elŝuti en originala formato", + "shareOriginalFormat": "Diskonigi en originala formato", + "shareDialogTitle": "Diskonigi %{resource} '%{name}'", + "shareBatchDialogTitle": "Diskonigi 1 %{resource} |||| Diskonigi %{smart_count} %{resource}", + "shareSuccess": "Ligilo kopiiĝis al la tondujo: %{url}", + "shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo", + "downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter", "remove_missing_title": "", - "remove_missing_content": "" + "remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.", + "remove_all_missing_title": "", + "remove_all_missing_content": "" }, "menu": { "library": "Biblioteko", @@ -436,22 +444,22 @@ "language": "Lingvo", "defaultView": "Defaŭlta Vido", "desktop_notifications": "Labortablaj sciigoj", - "lastfmScrobbling": "", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", + "lastfmScrobbling": "Scrobble al Last.fm", + "listenBrainzScrobbling": "Scrobble al ListenBrainz", + "replaygain": "ReplayGain-Reĝimo", + "preAmp": "ReplayGain PreAmp (dB)", "gain": { - "none": "", - "album": "", - "track": "" + "none": "Malebligita", + "album": "Uzi Albuman Songajnon", + "track": "Uzi Kantan Songajnon" }, "lastfmNotConfigured": "" } }, "albumList": "Albumoj", "about": "Pri", - "playlists": "", - "sharedPlaylists": "" + "playlists": "Ludlistoj", + "sharedPlaylists": "Diskonigitaj Ludistoj" }, "player": { "playListsText": "Atendovico", @@ -485,7 +493,7 @@ "featureRequests": "Trajta peto", "lastInsightsCollection": "", "insights": { - "disabled": "", + "disabled": "Malebligita", "waiting": "" } } @@ -496,7 +504,10 @@ "quickScan": "Rapida Skanado", "fullScan": "Plena Skanado", "serverUptime": "Servila daŭro de funkciado", - "serverDown": "SENKONEKTA" + "serverDown": "SENKONEKTA", + "scanType": "", + "status": "", + "elapsedTime": "" }, "help": { "title": "Navidrome klavkomando", @@ -509,7 +520,7 @@ "vol_up": "Pli volumo", "vol_down": "Malpli volumo", "toggle_love": "Baskuli la stelon de nuna kanto", - "current_song": "" + "current_song": "Iri al Nuna Kanto" } } } \ No newline at end of file diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 4c811b447..b640ec115 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -28,16 +28,19 @@ "channels": "Canales", "createdAt": "Creado el", "grouping": "Agrupación", - "mood": "", + "mood": "Estado de ánimo", "participants": "Participantes", "tags": "Etiquetas", "mappedTags": "Etiquetas asignadas", - "rawTags": "Etiquetas sin procesar" + "rawTags": "Etiquetas sin procesar", + "bitDepth": "Profundidad de bits", + "sampleRate": "Frecuencia de muestreo", + "missing": "Faltante" }, "actions": { "addToQueue": "Reproducir después", "playNow": "Reproducir ahora", - "addToPlaylist": "Agregar a la lista de reproducción", + "addToPlaylist": "Agregar a la playlist", "shuffleAll": "Todas aleatorias", "download": "Descarga", "playNext": "Siguiente", @@ -69,8 +72,10 @@ "catalogNum": "Número de catálogo", "releaseType": "Tipo de lanzamiento", "grouping": "Agrupación", - "media": "", - "mood": "" + "media": "Medios", + "mood": "Estado de ánimo", + "date": "Fecha de grabación", + "missing": "Faltante" }, "actions": { "playAll": "Reproducir", @@ -89,7 +94,7 @@ "recentlyPlayed": "Recientes", "mostPlayed": "Más reproducidos", "starred": "Favoritos", - "topRated": "Los mejores calificados" + "topRated": "Mejor calificados" } }, "artist": { @@ -102,7 +107,8 @@ "rating": "Calificación", "genre": "Género", "size": "Tamaño", - "role": "Rol" + "role": "Rol", + "missing": "Faltante" }, "roles": { "albumartist": "Artista del álbum", @@ -190,11 +196,12 @@ "addNewPlaylist": "Creada \"%{name}\"", "export": "Exportar", "makePublic": "Hazla pública", - "makePrivate": "Hazla privada" + "makePrivate": "Hazla privada", + "saveQueue": "Guardar la fila de reproducción en una playlist" }, "message": { - "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la lista de reproducción", - "song_exist": "Se están agregando duplicados a la lista de reproducción. ¿Quieres agregar los duplicados o omitirlos?" + "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", + "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?" } }, "radio": { @@ -235,11 +242,13 @@ "updatedAt": "Actualizado el" }, "actions": { - "remove": "Eliminar" + "remove": "Eliminar", + "remove_all": "Eliminar todo" }, "notifications": { "removed": "Eliminado" - } + }, + "empty": "No hay archivos perdidos" } }, "ra": { @@ -419,7 +428,9 @@ "downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro", "remove_missing_title": "Eliminar elemento faltante", - "remove_missing_content": "" + "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", + "remove_all_missing_title": "Eliminar todos los archivos perdidos", + "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones." }, "menu": { "library": "Biblioteca", @@ -451,7 +462,7 @@ "sharedPlaylists": "Playlists Compartidas" }, "player": { - "playListsText": "Lista de reproducción", + "playListsText": "Fila de reproducción", "openText": "Abrir", "closeText": "Cerrar", "notContentText": "Sin música", @@ -493,7 +504,10 @@ "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", "serverUptime": "Uptime del servidor", - "serverDown": "OFFLINE" + "serverDown": "OFFLINE", + "scanType": "Tipo", + "status": "Error de escaneo", + "elapsedTime": "Tiempo transcurrido" }, "help": { "title": "Atajos de teclado de Navidrome", diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 067310c14..5470ab38b 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -17,7 +17,10 @@ "year": "Urtea", "size": "Fitxategiaren tamaina", "updatedAt": "Eguneratze-data:", - "bitRate": "Bit tasa", + "bitRate": "Bit-tasa", + "bitDepth": "Bit-sakonera", + "sampleRate": "Lagin-tasa", + "channels": "Kanalak", "discSubtitle": "Diskoaren azpititulua", "starred": "Gogokoa", "comment": "Iruzkina", @@ -25,14 +28,13 @@ "quality": "Kalitatea", "bpm": "BPM", "playDate": "Azkenekoz erreproduzitua:", - "channels": "Kanalak", "createdAt": "Gehitu zen data:", - "grouping": "", - "mood": "", - "participants": "", - "tags": "", - "mappedTags": "", - "rawTags": "" + "grouping": "Multzokatzea", + "mood": "Aldartea", + "participants": "Partaide gehiago", + "tags": "Traola gehiago", + "mappedTags": "Esleitutako traolak", + "rawTags": "Traola gordinak" }, "actions": { "addToQueue": "Erreproduzitu ondoren", @@ -52,25 +54,26 @@ "duration": "Iraupena", "songCount": "abesti", "playCount": "Erreprodukzioak", + "size": "Fitxategiaren tamaina", "name": "Izena", "genre": "Generoa", "compilation": "Konpilazioa", "year": "Urtea", - "updatedAt": "Aktualizatze-data:", - "comment": "Iruzkina", - "rating": "Balorazioa", - "createdAt": "Gehitu zen data:", - "size": "Fitxategiaren tamaina", + "date": "Recording Date", "originalDate": "Jatorrizkoa", "releaseDate": "Argitaratze-data:", "releases": "Argitaratzea |||| Argitaratzeak", "released": "Argitaratua", - "recordLabel": "", - "catalogNum": "", - "releaseType": "", - "grouping": "", - "media": "", - "mood": "" + "updatedAt": "Aktualizatze-data:", + "comment": "Iruzkina", + "rating": "Balorazioa", + "createdAt": "Gehitu zen data:", + "recordLabel": "Disketxea", + "catalogNum": "Katalogo-zenbakia", + "releaseType": "Mota", + "grouping": "Multzokatzea", + "media": "Multimedia", + "mood": "Aldartea" }, "actions": { "playAll": "Erreproduzitu", @@ -84,7 +87,7 @@ }, "lists": { "all": "Guztiak", - "random": "Aleatorioa", + "random": "Aleatorioki", "recentlyAdded": "Berriki gehitutakoak", "recentlyPlayed": "Berriki entzundakoak", "mostPlayed": "Gehien entzundakoak", @@ -98,26 +101,26 @@ "name": "Izena", "albumCount": "Album kopurua", "songCount": "Abesti kopurua", + "size": "Tamaina", "playCount": "Erreprodukzio kopurua", "rating": "Balorazioa", "genre": "Generoa", - "size": "Tamaina", - "role": "" + "role": "Rola" }, "roles": { - "albumartist": "", - "artist": "", - "composer": "", - "conductor": "", - "lyricist": "", - "arranger": "", - "producer": "", - "director": "", - "engineer": "", - "mixer": "", - "remixer": "", - "djmixer": "", - "performer": "" + "albumartist": "Albumeko egilea |||| Albumeko artistak", + "artist": "Artista |||| Artistak", + "composer": "Konpositorea |||| Konpositoreak", + "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", + "lyricist": "Hitzen egilea |||| Hitzen egileak", + "arranger": "Moldatzailea |||| Moldatzaileak", + "producer": "Produktorea |||| Produktoreak", + "director": "Zuzendaria |||| Zuzendaria", + "engineer": "Teknikaria |||| Teknikariak", + "mixer": "Nahaslea |||| Nahasleak", + "remixer": "Remixerra |||| Remixerrak", + "djmixer": "DJ nahaslea |||| DJ nahasleak", + "performer": "Interpretatzailea |||| Interpretatzaileak" } }, "user": { @@ -238,7 +241,8 @@ "updatedAt": "Desagertze-data:" }, "actions": { - "remove": "Kendu" + "remove": "Kendu", + "remove_all": "Kendu guztia" }, "notifications": { "removed": "Faltan zeuden fitxategiak kendu dira" @@ -258,7 +262,7 @@ "sign_in": "Sartu", "sign_in_error": "Autentifikazioak huts egin du, saiatu berriro", "logout": "Amaitu saioa", - "insightsCollectionNote": "" + "insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero." }, "validation": { "invalidChars": "Erabili hizkiak eta zenbakiak bakarrik", @@ -398,31 +402,33 @@ "noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri", "delete_user_title": "Ezabatu '%{name}' erabiltzailea", "delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?", + "remove_missing_title": "Kendu faltan dauden fitxategiak", + "remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.", + "remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak", + "remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.", "notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu", "notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari", "lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago", "lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu", "lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da", "lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu", + "listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da", + "listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", + "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", "openIn": { "lastfm": "Ikusi Last.fm-n", "musicbrainz": "Ikusi MusicBrainz-en" }, "lastfmLink": "Irakurri gehiago…", - "listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da", - "listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", - "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", - "downloadOriginalFormat": "Deskargatu jatorrizko formatua", "shareOriginalFormat": "Partekatu jatorrizko formatua", "shareDialogTitle": "Partekatu '%{name}' %{resource}", "shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}", + "shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla", "shareSuccess": "URLa arbelera kopiatu da: %{url}", "shareFailure": "Errorea %{url} URLa arbelera kopiatzean", "downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})", - "shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla", - "remove_missing_title": "", - "remove_missing_content": "" + "downloadOriginalFormat": "Deskargatu jatorrizko formatua" }, "menu": { "library": "Liburutegia", @@ -436,6 +442,7 @@ "language": "Hizkuntza", "defaultView": "Bista, defektuz", "desktop_notifications": "Mahaigaineko jakinarazpenak", + "lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta", "lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak", "listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak", "replaygain": "ReplayGain modua", @@ -444,14 +451,13 @@ "none": "Bat ere ez", "album": "Albuma", "track": "Pista" - }, - "lastfmNotConfigured": "" + } } }, "albumList": "Albumak", - "about": "Honi buruz", "playlists": "Zerrendak", - "sharedPlaylists": "Partekatutako erreprodukzio-zerrendak" + "sharedPlaylists": "Partekatutako erreprodukzio-zerrendak", + "about": "Honi buruz" }, "player": { "playListsText": "Erreprodukzio-zerrenda", @@ -483,10 +489,10 @@ "homepage": "Hasierako orria", "source": "Iturburu kodea", "featureRequests": "Eskatu ezaugarria", - "lastInsightsCollection": "", + "lastInsightsCollection": "Bildutako azken datuak", "insights": { - "disabled": "", - "waiting": "" + "disabled": "Ezgaituta", + "waiting": "Zain" } } }, @@ -496,7 +502,10 @@ "quickScan": "Arakatze azkarra", "fullScan": "Arakatze sakona", "serverUptime": "Zerbitzariak piztuta daraman denbora", - "serverDown": "LINEAZ KANPO" + "serverDown": "LINEAZ KANPO", + "scanType": "Mota", + "status": "Errorea arakatzean", + "elapsedTime": "Igarotako denbora" }, "help": { "title": "Navidromeren laster-teklak", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 6c084a196..92e43934f 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -32,7 +32,10 @@ "participants": "Lisäosallistujat", "tags": "Lisätunnisteet", "mappedTags": "Mäpättyt tunnisteet", - "rawTags": "Raakatunnisteet" + "rawTags": "Raakatunnisteet", + "bitDepth": "Bittisyvyys", + "sampleRate": "Näytteenottotaajuus", + "missing": "" }, "actions": { "addToQueue": "Lisää jonoon", @@ -70,7 +73,9 @@ "releaseType": "Tyyppi", "grouping": "Ryhmittely", "media": "Media", - "mood": "Tunnelma" + "mood": "Tunnelma", + "date": "Tallennuspäivä", + "missing": "" }, "actions": { "playAll": "Soita", @@ -102,7 +107,8 @@ "rating": "Arvostelu", "genre": "Tyylilaji", "size": "Koko", - "role": "Rooli" + "role": "Rooli", + "missing": "" }, "roles": { "albumartist": "Albumitaiteilija |||| Albumitaiteilijat", @@ -190,7 +196,8 @@ "addNewPlaylist": "Luo \"%{name}\"", "export": "Vie", "makePublic": "Tee julkinen", - "makePrivate": "Tee yksityinen" + "makePrivate": "Tee yksityinen", + "saveQueue": "" }, "message": { "duplicate_song": "Lisää olemassa oleva kappale", @@ -235,11 +242,13 @@ "updatedAt": "Katosi" }, "actions": { - "remove": "Poista" + "remove": "Poista", + "remove_all": "" }, "notifications": { "removed": "Puuttuvat tiedostot poistettu" - } + }, + "empty": "Ei puuttuvia tiedostoja" } }, "ra": { @@ -419,7 +428,9 @@ "downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter", "remove_missing_title": "Poista puuttuvat tiedostot", - "remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut." + "remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.", + "remove_all_missing_title": "", + "remove_all_missing_content": "" }, "menu": { "library": "Kirjasto", @@ -493,7 +504,10 @@ "quickScan": "Nopea tarkistus", "fullScan": "Täysi tarkistus", "serverUptime": "Palvelun käyttöaika", - "serverDown": "SAMMUTETTU" + "serverDown": "SAMMUTETTU", + "scanType": "", + "status": "", + "elapsedTime": "" }, "help": { "title": "Navidrome pikapainikkeet", diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 4060a789d..69250ef18 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -2,7 +2,7 @@ "languageName": "Français", "resources": { "song": { - "name": "Piste |||| Pistes", + "name": "Titre |||| Titres", "fields": { "albumArtist": "Artiste", "duration": "Durée", @@ -33,7 +33,9 @@ "tags": "Étiquettes supplémentaires", "mappedTags": "Étiquettes correspondantes", "rawTags": "Étiquettes brutes", - "bitDepth": "Profondeur de bit" + "bitDepth": "Profondeur de bits", + "sampleRate": "Fréquence d'échantillonnage", + "missing": "Manquant" }, "actions": { "addToQueue": "Ajouter à la file", @@ -42,7 +44,8 @@ "shuffleAll": "Tout mélanger", "download": "Télécharger", "playNext": "Jouer ensuite", - "info": "Plus d'informations" + "info": "Plus d'informations", + "showInPlaylist": "Montrer dans la playlist" } }, "album": { @@ -51,7 +54,7 @@ "albumArtist": "Artiste", "artist": "Artiste", "duration": "Durée", - "songCount": "Nombre de pistes", + "songCount": "Titres", "playCount": "Nombre d'écoutes", "name": "Nom", "genre": "Genre", @@ -71,7 +74,9 @@ "releaseType": "Type", "grouping": "Regroupement", "media": "Média", - "mood": "Humeur" + "mood": "Humeur", + "date": "Date d'enregistrement", + "missing": "Manquant" }, "actions": { "playAll": "Lire", @@ -98,12 +103,13 @@ "fields": { "name": "Nom", "albumCount": "Nombre d'albums", - "songCount": "Nombre de pistes", + "songCount": "Nombre de titres", "playCount": "Lectures", "rating": "Classement", "genre": "Genre", "size": "Taille", - "role": "Rôle" + "role": "Rôle", + "missing": "Manquant" }, "roles": { "albumartist": "Artiste de l'album |||| Artistes de l'album", @@ -118,7 +124,13 @@ "mixer": "Mixeur |||| Mixeurs", "remixer": "Remixeur |||| Remixeurs", "djmixer": "Mixeur DJ |||| Mixeurs DJ", - "performer": "Interprète |||| Interprètes" + "performer": "Interprète |||| Interprètes", + "maincredit": "Artiste de l'album ou Artiste |||| Artistes de l'album ou Artistes" + }, + "actions": { + "shuffle": "Lecture aléatoire", + "radio": "Radio", + "topSongs": "Meilleurs titres" } }, "user": { @@ -187,15 +199,22 @@ "path": "Importer depuis" }, "actions": { - "selectPlaylist": "Ajouter les pistes à la playlist", + "selectPlaylist": "Sélectionner une playlist :", "addNewPlaylist": "Créer \"%{name}\"", "export": "Exporter", "makePublic": "Rendre publique", - "makePrivate": "Rendre privée" + "makePrivate": "Rendre privée", + "saveQueue": "Sauvegarder la file de lecture dans la playlist", + "searchOrCreate": "Chercher ou créer une nouvelle playlist...", + "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", + "removeFromSelection": "Supprimer de la sélection", + "removeSymbol": "×" }, "message": { - "duplicate_song": "Pistes déjà présentes dans la playlist", - "song_exist": "Certaines des pistes sélectionnées font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?" + "duplicate_song": "Ajouter les titres déjà présents dans la playlist", + "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?", + "noPlaylistsFound": "Aucune playlist trouvée", + "noPlaylists": "Aucune playlist disponible" } }, "radio": { @@ -236,7 +255,8 @@ "updatedAt": "A disparu le" }, "actions": { - "remove": "Supprimer" + "remove": "Supprimer", + "remove_all": "Tout supprimer" }, "notifications": { "removed": "Fichier(s) manquant(s) supprimé(s)" @@ -393,7 +413,7 @@ "note": "NOTE", "transcodingDisabled": "Le changement de paramètres depuis l'interface web est désactivé pour des raisons de sécurité. Pour changer (éditer ou supprimer) les options de transcodage, relancer le serveur avec l'option %{config} activée.", "transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé d'activer cette fonctionnalité uniquement lors de la configuration du transcodage.", - "songsAddedToPlaylist": "Une piste a été ajoutée à la playlist |||| %{smart_count} pistes ont été ajoutées à la playlist", + "songsAddedToPlaylist": "1 titre a été ajouté à la playlist |||| %{smart_count} titres ont été ajoutés à la playlist", "noPlaylistsAvailable": "Aucune playlist", "delete_user_title": "Supprimer l'utilisateur '%{name}'", "delete_user_content": "Êtes-vous sûr(e) de vouloir supprimer cet utilisateur et ses données associées (y compris ses playlists et préférences) ?", @@ -421,7 +441,11 @@ "downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter", "remove_missing_title": "Supprimer les fichiers manquants", - "remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations" + "remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations", + "remove_all_missing_title": "Supprimer tous les fichiers manquants", + "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.", + "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé", + "noTopSongsFound": "Aucun meilleur titre n'a été trouvé" }, "menu": { "library": "Bibliothèque", @@ -442,7 +466,7 @@ "gain": { "none": "Désactivé", "album": "Utiliser le gain de l'album", - "track": "Utiliser le gain des pistes" + "track": "Utiliser le gain des titres" }, "lastfmNotConfigured": "La clef API de Last.fm n'est pas configurée" } @@ -487,6 +511,21 @@ "disabled": "Désactivée", "waiting": "En attente" } + }, + "tabs": { + "about": "À propos", + "config": "Paramètres" + }, + "config": { + "configName": "Nom de la configuration", + "environmentVariable": "Variable d'environnement", + "currentValue": "Valeur actuelle", + "configurationFile": "Fichier de configuration", + "exportToml": "Exporter la configuration (TOML)", + "exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML", + "exportFailed": "Une erreur est survenue en copiant la configuration", + "devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)", + "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur" } }, "activity": { @@ -495,7 +534,10 @@ "quickScan": "Scan rapide", "fullScan": "Scan complet", "serverUptime": "Disponibilité du serveur", - "serverDown": "HORS LIGNE" + "serverDown": "HORS LIGNE", + "scanType": "Type", + "status": "Erreur de scan", + "elapsedTime": "Temps écoulé" }, "help": { "title": "Raccourcis Navidrome", @@ -508,7 +550,12 @@ "vol_up": "Augmenter le volume", "vol_down": "Baisser le volume", "toggle_love": "Ajouter/Enlever le morceau des favoris", - "current_song": "Aller à la chanson en cours" + "current_song": "Aller au titre en cours" } + }, + "nowPlaying": { + "title": "En cours de lecture", + "empty": "Aucun titre en cours de lecture", + "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } } \ No newline at end of file diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json index f70726520..8eb1a04f1 100644 --- a/resources/i18n/hu.json +++ b/resources/i18n/hu.json @@ -18,6 +18,8 @@ "size": "Fájlméret", "updatedAt": "Legutóbb frissítve", "bitRate": "Bitráta", + "bitDepth": "Bitmélység", + "sampleRate": "Mintavételezési frekvencia", "discSubtitle": "Lemezfelirat", "starred": "Kedvenc", "comment": "Megjegyzés", @@ -32,7 +34,8 @@ "participants": "További résztvevők", "tags": "További címkék", "mappedTags": "Feldolgozott címkék", - "rawTags": "Nyers címkék" + "rawTags": "Nyers címkék", + "missing": "Hiányzó" }, "actions": { "addToQueue": "Lejátszás útolsóként", @@ -56,6 +59,7 @@ "genre": "Stílus", "compilation": "Válogatásalbum", "year": "Év", + "date": "Felvétel dátuma", "updatedAt": "Legutóbb frissítve", "comment": "Megjegyzés", "rating": "Értékelés", @@ -70,7 +74,8 @@ "releaseType": "Típus", "grouping": "Csoportosítás", "media": "Média", - "mood": "Hangulat" + "mood": "Hangulat", + "missing": "Hiányzó" }, "actions": { "playAll": "Lejátszás", @@ -102,7 +107,8 @@ "rating": "Értékelés", "genre": "Stílus", "size": "Méret", - "role": "Szerep" + "role": "Szerep", + "missing": "Hiányzó" }, "roles": { "albumartist": "Album előadó |||| Album előadók", @@ -189,6 +195,7 @@ "selectPlaylist": "Válassz egy lejátszási listát:", "addNewPlaylist": "\"%{name}\" létrehozása", "export": "Exportálás", + "saveQueue": "Műsorlista elmentése lejátszási listaként", "makePublic": "Publikussá tétel", "makePrivate": "Priváttá tétel" }, @@ -229,13 +236,15 @@ }, "missing": { "name": "Hiányzó fájl|||| Hiányzó fájlok", + "empty": "Nincsenek hiányzó fájlok", "fields": { "path": "Útvonal", "size": "Méret", "updatedAt": "Eltűnt ekkor:" }, "actions": { - "remove": "Eltávolítás" + "remove": "Eltávolítás", + "remove_all": "Összes eltávolítása" }, "notifications": { "removed": "Hiányzó fájl(ok) eltávolítva" @@ -395,6 +404,8 @@ "noPlaylistsAvailable": "Nem áll rendelkezésre", "delete_user_title": "Felhasználó törlése '%{name}'", "delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?", + "remove_all_missing_title": "Összes hiányzó fájl eltávolítása", + "remove_all_missing_content": "Biztos, hogy minden hiányzó fájlt törölni akarsz az adatbázisból? Ez minden hozzájuk fűződő referenciát törölni fog, beleértve a lejátszásaikat és értékeléseiket.", "notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.", "notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.", "lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.", @@ -406,7 +417,7 @@ "musicbrainz": "Megnyitás MusicBrainz-ben" }, "lastfmLink": "Bővebben...", - "listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el és halgatott számok küldése %{user} felhasználónak engedélyezve.", + "listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. Halgatott számok küldése %{user} felhasználónak engedélyezve.", "listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}", "listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.", "listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.", @@ -451,7 +462,7 @@ "sharedPlaylists": "Megosztott lej. listák" }, "player": { - "playListsText": "Lejátszási lista", + "playListsText": "Műsorlista", "openText": "Megnyitás", "closeText": "Bezárás", "notContentText": "Nincs zene", @@ -493,7 +504,10 @@ "quickScan": "Gyors beolvasás", "fullScan": "Teljes beolvasás", "serverUptime": "Szerver üzemidő", - "serverDown": "OFFLINE" + "serverDown": "OFFLINE", + "scanType": "Típus", + "status": "Szkennelési hiba", + "elapsedTime": "Eltelt idő" }, "help": { "title": "Navidrome Gyorsbillentyűk", diff --git a/resources/i18n/id.json b/resources/i18n/id.json index 3269b37b1..f97d3366b 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -32,7 +32,10 @@ "participants": "Partisipan tambahan", "tags": "Tag tambahan", "mappedTags": "Tag yang dipetakan", - "rawTags": "Tag raw" + "rawTags": "Tag raw", + "bitDepth": "Bit depth", + "sampleRate": "Sample rate", + "missing": "Hilang" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -41,7 +44,8 @@ "shuffleAll": "Acak Semua", "download": "Unduh", "playNext": "Putar Berikutnya", - "info": "Lihat Info" + "info": "Lihat Info", + "showInPlaylist": "Tampilkan di Playlist" } }, "album": { @@ -70,7 +74,9 @@ "releaseType": "Tipe", "grouping": "Pengelompokkan", "media": "Media", - "mood": "Mood" + "mood": "Mood", + "date": "Tanggal Perekaman", + "missing": "Hilang" }, "actions": { "playAll": "Putar", @@ -102,7 +108,8 @@ "rating": "Peringkat", "genre": "Genre", "size": "Ukuran", - "role": "Peran" + "role": "Peran", + "missing": "Hilang" }, "roles": { "albumartist": "Artis Album |||| Artis Album", @@ -117,7 +124,13 @@ "mixer": "Mixer |||| Mixer", "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| Dj Mixer", - "performer": "Performer |||| Performer" + "performer": "Performer |||| Performer", + "maincredit": "" + }, + "actions": { + "shuffle": "", + "radio": "", + "topSongs": "" } }, "user": { @@ -163,7 +176,7 @@ } }, "transcoding": { - "name": "Transkode |||| Transkode", + "name": "Transkoding |||| Transkoding", "fields": { "name": "Nama", "targetFormat": "Target Format", @@ -190,11 +203,18 @@ "addNewPlaylist": "Buat \"%{name}\"", "export": "Ekspor", "makePublic": "Jadikan Publik", - "makePrivate": "Jadikan Pribadi" + "makePrivate": "Jadikan Pribadi", + "saveQueue": "Simpan Antrean ke Playlist", + "searchOrCreate": "Cari playlist atau ketik untuk buat baru..", + "pressEnterToCreate": "Tekan Enter untuk membuat playlist baru", + "removeFromSelection": "Hapus yang dipilih", + "removeSymbol": "×" }, "message": { "duplicate_song": "Tambahkan lagu duplikat", - "song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?" + "song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?", + "noPlaylistsFound": "Playlist tidak ditemukan", + "noPlaylists": "Playlist tidak tersedia" } }, "radio": { @@ -235,11 +255,13 @@ "updatedAt": "Tidak muncul di" }, "actions": { - "remove": "Hapus" + "remove": "Hapus", + "remove_all": "Hapus Semua" }, "notifications": { "removed": "File yang hilang dihapus" - } + }, + "empty": "Tidak ada File yang Hilang" } }, "ra": { @@ -277,7 +299,7 @@ "add": "Tambah", "back": "Kembali", "bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih", - "cancel": "Batalkan", + "cancel": "Batal", "clear_input_value": "Hapus", "clone": "Klon", "confirm": "Konfirmasi", @@ -292,7 +314,7 @@ "save": "Simpan", "search": "Cari", "show": "Tampilkan", - "sort": "Sortir", + "sort": "Urutkan", "undo": "Batalkan", "expand": "Luaskan", "close": "Tutup", @@ -312,7 +334,7 @@ "create": "Buat %{name}", "dashboard": "Dasbor", "edit": "%{name} #%{id}", - "error": "Ada yang tidak beres", + "error": "Terjadi kesalahan", "list": "%{name}", "loading": "Memuat", "not_found": "Tidak ditemukan", @@ -356,7 +378,7 @@ "unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?" }, "navigation": { - "no_results": "Tidak ada hasil yang ditemukan", + "no_results": "Hasil tidak ditemukan", "no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.", "page_out_of_boundaries": "Nomor halaman %{page} melampaui batas", "page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir", @@ -371,8 +393,8 @@ "updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui", "created": "Elemen dibuat", "deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus", - "bad_item": "Elemen salah", - "item_doesnt_exist": "Tidak ada elemen", + "bad_item": "Kesalahan elemen", + "item_doesnt_exist": "Elemen tidak ditemukan", "http_error": "Kesalahan komunikasi peladen", "data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.", "i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur", @@ -419,7 +441,11 @@ "downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter", "remove_missing_title": "Hapus file yang hilang", - "remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya." + "remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.", + "remove_all_missing_title": "Hapus semua file yang hilang", + "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", + "noSimilarSongsFound": "", + "noTopSongsFound": "" }, "menu": { "library": "Pustaka", @@ -451,7 +477,7 @@ "sharedPlaylists": "Playlist yang Dibagikan" }, "player": { - "playListsText": "Mainkan Antrean", + "playListsText": "Putar Antrean", "openText": "Buka", "closeText": "Tutup", "notContentText": "Tidak ada musik", @@ -471,7 +497,7 @@ "playModeText": { "order": "Berurutan", "orderLoop": "Ulang", - "singleLoop": "Ulangi Satu", + "singleLoop": "Ulangi Sekali", "shufflePlay": "Acak" } }, @@ -485,6 +511,21 @@ "disabled": "Nonaktifkan", "waiting": "Menunggu" } + }, + "tabs": { + "about": "Tentang", + "config": "Konfigurasi" + }, + "config": { + "configName": "Nama Konfigurasi", + "environmentVariable": "", + "currentValue": "Value Saat Ini", + "configurationFile": "File Konfigurasi", + "exportToml": "Ekspor Konfigurasi (TOML)", + "exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML", + "exportFailed": "Gagal menyalin konfigurasi", + "devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)", + "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang" } }, "activity": { @@ -493,7 +534,10 @@ "quickScan": "Pemindaian Cepat", "fullScan": "Pemindaian Penuh", "serverUptime": "Waktu Aktif Peladen", - "serverDown": "LURING" + "serverDown": "LURING", + "scanType": "Tipe", + "status": "Kesalahan Memindai", + "elapsedTime": "Waktu Berakhir" }, "help": { "title": "Tombol Pintasan Navidrome", @@ -508,5 +552,10 @@ "toggle_love": "Tambahkan lagu ini ke favorit", "current_song": "Buka Lagu Saat Ini" } + }, + "nowPlaying": { + "title": "", + "empty": "", + "minutesAgo": "" } } \ No newline at end of file diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 07e222e2a..4737cb33a 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -26,7 +26,16 @@ "bpm": "BPM", "playDate": "Laatst afgespeeld", "channels": "Kanalen", - "createdAt": "Datum toegevoegd" + "createdAt": "Datum toegevoegd", + "grouping": "Groep", + "mood": "Sfeer", + "participants": "Extra deelnemers", + "tags": "Extra tags", + "mappedTags": "Gemapte tags", + "rawTags": "Onbewerkte tags", + "bitDepth": "Bit diepte", + "sampleRate": "Sample waarde", + "missing": "Ontbrekend" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", @@ -58,7 +67,15 @@ "originalDate": "Origineel", "releaseDate": "Uitgegeven", "releases": "Uitgave |||| Uitgaven", - "released": "Uitgegeven" + "released": "Uitgegeven", + "recordLabel": "Label", + "catalogNum": "Catalogus nummer", + "releaseType": "Type", + "grouping": "Groep", + "media": "Media", + "mood": "Sfeer", + "date": "Opnamedatum", + "missing": "Ontbrekend" }, "actions": { "playAll": "Afspelen", @@ -89,7 +106,24 @@ "playCount": "Afgespeeld", "rating": "Beoordeling", "genre": "Genre", - "size": "Grootte" + "size": "Grootte", + "role": "Rol", + "missing": "Ontbrekend" + }, + "roles": { + "albumartist": "Album artiest |||| Album artiesten", + "artist": "Artiest |||| Artiesten", + "composer": "Componist |||| Componisten", + "conductor": "Dirigent |||| Dirigenten", + "lyricist": "Tekstschrijver |||| Tekstschrijvers", + "arranger": "Arrangeur |||| Arrangeurs", + "producer": "Producent |||| Producenten", + "director": "Regisseur |||| Regisseurs", + "engineer": "Opnametechnicus |||| Opnametechnici", + "mixer": "Mixer |||| Mixers", + "remixer": "Remixer |||| Remixers", + "djmixer": "DJ Mixer |||| DJ Mixers", + "performer": "Performer |||| Performers" } }, "user": { @@ -162,7 +196,8 @@ "addNewPlaylist": "Creëer \"%{name}\"", "export": "Exporteer", "makePublic": "Openbaar maken", - "makePrivate": "Privé maken" + "makePrivate": "Privé maken", + "saveQueue": "Bewaar wachtrij als playlist" }, "message": { "duplicate_song": "Dubbele nummers toevoegen", @@ -198,6 +233,22 @@ "createdAt": "Gecreëerd op", "downloadable": "Downloads toestaan?" } + }, + "missing": { + "name": "Ontbrekend bestand |||| Ontbrekende bestanden", + "fields": { + "path": "Pad", + "size": "Grootte", + "updatedAt": "Verdwenen op" + }, + "actions": { + "remove": "Verwijder", + "remove_all": "Alles verwijderen" + }, + "notifications": { + "removed": "Ontbrekende bestanden verwijderd" + }, + "empty": "Geen ontbrekende bestanden" } }, "ra": { @@ -212,7 +263,8 @@ "password": "Wachtwoord", "sign_in": "Inloggen", "sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.", - "logout": "Uitloggen" + "logout": "Uitloggen", + "insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren" }, "validation": { "invalidChars": "Gebruik alleen letters en cijfers", @@ -374,7 +426,11 @@ "shareSuccess": "URL gekopieeerd naar klembord: %{url}", "shareFailure": "Fout bij kopieren URL %{url} naar klembord", "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter" + "shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter", + "remove_missing_title": "Verwijder ontbrekende bestanden", + "remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.", + "remove_all_missing_title": "Verwijder alle ontbrekende bestanden", + "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen." }, "menu": { "library": "Bibliotheek", @@ -396,16 +452,17 @@ "none": "Uitgeschakeld", "album": "Gebruik Album Gain", "track": "Gebruik Track Gain" - } + }, + "lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd" } }, "albumList": "Albums", "about": "Over", - "playlists": "Playlists", - "sharedPlaylists": "Gedeelde playlists" + "playlists": "Afspeellijsten", + "sharedPlaylists": "Gedeelde afspeellijsten" }, "player": { - "playListsText": "Afspeellijst afspelen", + "playListsText": "Wachtrij", "openText": "Openen", "closeText": "Sluiten", "notContentText": "Geen muziek", @@ -433,7 +490,12 @@ "links": { "homepage": "Thuispagina", "source": "Broncode", - "featureRequests": "Functie verzoeken" + "featureRequests": "Functie verzoeken", + "lastInsightsCollection": "Laatste inzichten", + "insights": { + "disabled": "Uitgeschakeld", + "waiting": "Wachten" + } } }, "activity": { @@ -442,7 +504,10 @@ "quickScan": "Snelle scan", "fullScan": "Volledige scan", "serverUptime": "Server uptime", - "serverDown": "Offline" + "serverDown": "Offline", + "scanType": "Type", + "status": "Scan fout", + "elapsedTime": "Verlopen tijd" }, "help": { "title": "Navidrome sneltoetsen", diff --git a/resources/i18n/pt.json b/resources/i18n/pt-br.json similarity index 87% rename from resources/i18n/pt.json rename to resources/i18n/pt-br.json index 59e7a775d..3fa5b16eb 100644 --- a/resources/i18n/pt.json +++ b/resources/i18n/pt-br.json @@ -1,5 +1,5 @@ { - "languageName": "Português", + "languageName": "Português (Brasil)", "resources": { "song": { "name": "Música |||| Músicas", @@ -18,7 +18,6 @@ "size": "Tamanho", "updatedAt": "Últ. Atualização", "bitRate": "Bitrate", - "bitDepth": "Profundidade de bits", "discSubtitle": "Sub-título do disco", "starred": "Favorita", "comment": "Comentário", @@ -33,7 +32,10 @@ "participants": "Outros Participantes", "tags": "Outras Tags", "mappedTags": "Tags mapeadas", - "rawTags": "Tags originais" + "rawTags": "Tags originais", + "bitDepth": "Profundidade de bits", + "sampleRate": "Taxa de amostragem", + "missing": "Ausente" }, "actions": { "addToQueue": "Adicionar à fila", @@ -42,7 +44,8 @@ "shuffleAll": "Aleatório", "download": "Baixar", "playNext": "Toca a seguir", - "info": "Detalhes" + "info": "Detalhes", + "showInPlaylist": "Ir para playlist" } }, "album": { @@ -57,7 +60,6 @@ "genre": "Gênero", "compilation": "Coletânea", "year": "Ano", - "date": "Data de Lançamento", "updatedAt": "Últ. Atualização", "comment": "Comentário", "rating": "Classificação", @@ -72,7 +74,9 @@ "releaseType": "Tipo", "grouping": "Agrupamento", "media": "Mídia", - "mood": "Mood" + "mood": "Mood", + "date": "Data de Lançamento", + "missing": "Ausente" }, "actions": { "playAll": "Tocar", @@ -104,7 +108,8 @@ "rating": "Classificação", "genre": "Gênero", "size": "Tamanho", - "role": "Role" + "role": "Role", + "missing": "Ausente" }, "roles": { "albumartist": "Artista do Álbum |||| Artistas do Álbum", @@ -119,7 +124,13 @@ "mixer": "Mixador |||| Mixadores", "remixer": "Remixador |||| Remixadores", "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Músico |||| Músicos" + "performer": "Músico |||| Músicos", + "maincredit": "Artista do Álbum ou Artista |||| Artistas do Álbum ou Artistas" + }, + "actions": { + "topSongs": "Mais tocadas", + "shuffle": "Aleatório", + "radio": "Rádio" } }, "user": { @@ -192,11 +203,18 @@ "addNewPlaylist": "Criar \"%{name}\"", "export": "Exportar", "makePublic": "Pública", - "makePrivate": "Pessoal" + "makePrivate": "Pessoal", + "saveQueue": "Salvar fila em nova Playlist", + "searchOrCreate": "Buscar playlists ou criar nova...", + "pressEnterToCreate": "Pressione Enter para criar nova playlist", + "removeFromSelection": "Remover da seleção", + "removeSymbol": "×" }, "message": { "duplicate_song": "Adicionar músicas duplicadas", - "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?" + "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", + "noPlaylistsFound": "Nenhuma playlist encontrada", + "noPlaylists": "Nenhuma playlist disponível" } }, "radio": { @@ -231,18 +249,19 @@ }, "missing": { "name": "Arquivo ausente |||| Arquivos ausentes", - "empty": "Nenhum arquivo ausente", "fields": { "path": "Caminho", "size": "Tamanho", "updatedAt": "Desaparecido em" }, "actions": { - "remove": "Remover" + "remove": "Remover", + "remove_all": "Remover todos" }, "notifications": { "removed": "Arquivo(s) ausente(s) removido(s)" - } + }, + "empty": "Nenhum arquivo ausente" } }, "ra": { @@ -395,6 +414,8 @@ "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", "songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist", + "noSimilarSongsFound": "Nenhuma música semelhante encontrada", + "noTopSongsFound": "Nenhuma música mais tocada encontrada", "noPlaylistsAvailable": "Nenhuma playlist", "delete_user_title": "Excluir usuário '%{name}'", "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", @@ -422,7 +443,9 @@ "downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter", "remove_missing_title": "Remover arquivos ausentes", - "remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações." + "remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", + "remove_all_missing_title": "Remover todos os arquivos ausentes", + "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações." }, "menu": { "library": "Biblioteca", @@ -488,6 +511,21 @@ "disabled": "Desligado", "waiting": "Aguardando" } + }, + "tabs": { + "about": "Sobre", + "config": "Configuração" + }, + "config": { + "configName": "Nome da Configuração", + "environmentVariable": "Variável de Ambiente", + "currentValue": "Valor Atual", + "configurationFile": "Arquivo de Configuração", + "exportToml": "Exportar Configuração (TOML)", + "exportSuccess": "Configuração exportada para o clipboard em formato TOML", + "exportFailed": "Falha ao copiar configuração", + "devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)", + "devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras" } }, "activity": { @@ -496,7 +534,15 @@ "quickScan": "Scan rápido", "fullScan": "Scan completo", "serverUptime": "Uptime do servidor", - "serverDown": "DESCONECTADO" + "serverDown": "DESCONECTADO", + "scanType": "Tipo", + "status": "Erro", + "elapsedTime": "Duração" + }, + "nowPlaying": { + "title": "Tocando agora", + "empty": "Nada tocando", + "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás" }, "help": { "title": "Teclas de atalho", @@ -512,4 +558,4 @@ "current_song": "Vai para música atual" } } -} \ No newline at end of file +} diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index a4de263e4..54378acd3 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -8,7 +8,7 @@ "duration": "Длительность", "trackNumber": "#", "playCount": "Проигрывания", - "title": "Название", + "title": "Название трека", "artist": "Исполнитель", "album": "Альбом", "path": "Путь", @@ -23,7 +23,7 @@ "comment": "Комментарий", "rating": "Рейтинг", "quality": "Качество", - "bpm": "Кол-во ударов в минуту", + "bpm": "BPM", "playDate": "Последнее воспроизведение", "channels": "Каналы", "createdAt": "Дата добавления", @@ -33,8 +33,9 @@ "tags": "Дополнительные теги", "mappedTags": "Сопоставленные теги", "rawTags": "Исходные теги", - "bitDepth": "Битовая глубина", - "sampleRate": "Частота дискретизации (Гц)" + "bitDepth": "Битовая глубина (Bit)", + "sampleRate": "Частота дискретизации (Hz)", + "missing": "Поле отсутствует" }, "actions": { "addToQueue": "В очередь", @@ -43,7 +44,8 @@ "shuffleAll": "Перемешать", "download": "Скачать", "playNext": "Следующий", - "info": "Информация" + "info": "Информация", + "showInPlaylist": "Показать в плейлисте" } }, "album": { @@ -54,7 +56,7 @@ "duration": "Длительность", "songCount": "Треков", "playCount": "Проигрывания", - "name": "Название", + "name": "Название альбома", "genre": "Жанр", "compilation": "Сборник", "year": "Год", @@ -73,7 +75,8 @@ "grouping": "Группирование", "media": "Медиа", "mood": "Настроение", - "date": "Дата записи" + "date": "Дата записи", + "missing": "Поле отсутствует" }, "actions": { "playAll": "Играть", @@ -98,14 +101,15 @@ "artist": { "name": "Исполнитель |||| Исполнители", "fields": { - "name": "Название", + "name": "Название исполнителя", "albumCount": "Количество альбомов", "songCount": "Количество треков", "playCount": "Проигрывания", "rating": "Рейтинг", "genre": "Жанр", "size": "Размер", - "role": "Роль" + "role": "Роль", + "missing": "Поле отсутствует" }, "roles": { "albumartist": "Исполнитель альбома |||| Исполнители альбома", @@ -120,7 +124,13 @@ "mixer": "Звукоинженер |||| Звукоинженеры", "remixer": "Ремиксер |||| Ремиксеры", "djmixer": "DJ-миксер |||| DJ-миксеры", - "performer": "Исполнитель |||| Исполнители" + "performer": "Исполнитель |||| Исполнители", + "maincredit": "" + }, + "actions": { + "shuffle": "Смешать", + "radio": "Радио", + "topSongs": "Топовые треки" } }, "user": { @@ -132,7 +142,7 @@ "updatedAt": "Обновлено", "name": "Имя", "password": "Пароль", - "createdAt": "Создан", + "createdAt": "Аккаунт создан", "changePassword": "Сменить пароль?", "currentPassword": "Текущий пароль", "newPassword": "Новый пароль", @@ -157,7 +167,7 @@ "fields": { "name": "Имя", "transcodingId": "Транскодирование", - "maxBitRate": "Макс. Битрейт", + "maxBitRate": "Макс. битрейт", "client": "Клиент", "userName": "Пользователь", "lastSeen": "Был на сайте", @@ -175,9 +185,9 @@ } }, "playlist": { - "name": "Плейлистов |||| Плейлисты", + "name": "Плейлист |||| Плейлисты", "fields": { - "name": "Название", + "name": "Название трека", "duration": "Длительность", "ownerName": "Владелец", "public": "Публичный", @@ -193,11 +203,18 @@ "addNewPlaylist": "Создать \"%{name}\"", "export": "Экспорт", "makePublic": "Опубликовать", - "makePrivate": "Сделать личным" + "makePrivate": "Сделать личным", + "saveQueue": "Сохранить очередь в плейлист", + "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", + "pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения", + "removeFromSelection": "Удалить из списка выделенных", + "removeSymbol": "×" }, "message": { "duplicate_song": "Повторяющиеся треки", - "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?" + "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?", + "noPlaylistsFound": "Плейлисты не найдены", + "noPlaylists": "Нет доступных плейлистов" } }, "radio": { @@ -222,9 +239,9 @@ "contents": "Содержание", "expiresAt": "Ссылка истекает", "lastVisitedAt": "Последнее посещение", - "visitCount": "Посещения", + "visitCount": "Количество посещений", "format": "Формат", - "maxBitRate": "Макс. Битрейт", + "maxBitRate": "Макс. битрейт", "updatedAt": "Обновлено в", "createdAt": "Создано", "downloadable": "Разрешить загрузку?" @@ -238,7 +255,8 @@ "updatedAt": "Исчез" }, "actions": { - "remove": "Удалить" + "remove": "Удалить", + "remove_all": "Убрать все" }, "notifications": { "removed": "Отсутствующие файлы удалены" @@ -274,7 +292,7 @@ "oneOf": "Должно быть одним из: %{options}", "regex": "Должно быть в формате (regexp): %{pattern}", "unique": "Должно быть уникальным", - "url": "Должен быть действительным URL адрес" + "url": "Должен быть действительный URL" }, "action": { "add_filter": "Фильтр", @@ -291,7 +309,7 @@ "export": "Экспорт", "list": "Список", "refresh": "Обновить", - "remove_filter": "Убрать фильтр", + "remove_filter": "Убрать этот фильтр", "remove": "Удалить", "save": "Сохранить", "search": "Поиск", @@ -382,7 +400,7 @@ "i18n_error": "Не удалось загрузить перевод для указанного языка", "canceled": "Операция отменена", "logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова", - "new_version": "Доступна новая версия! Пожалуйста, обновите это окно" + "new_version": "Доступна новая версия! Пожалуйста, обновите это окно." }, "toggleFieldsMenu": { "columnsToDisplay": "Отображение столбцов", @@ -422,8 +440,12 @@ "shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена", "downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter", - "remove_missing_title": "Удалить отсутствующие файлы", - "remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах." + "remove_missing_title": "Удалить отсутствующие файлы?", + "remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.", + "remove_all_missing_title": "Удалите все отсутствующие файлы", + "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.", + "noSimilarSongsFound": "Похожих треков не найдено", + "noTopSongsFound": "Лучших треков не найдено" }, "menu": { "library": "Библиотека", @@ -482,13 +504,28 @@ "about": { "links": { "homepage": "Главная", - "source": "Код", + "source": "Исходный код", "featureRequests": "Предложения", "lastInsightsCollection": "Последний сбор данных", "insights": { "disabled": "Выключено", "waiting": "Ожидание" } + }, + "tabs": { + "about": "О нас", + "config": "Конфигурация" + }, + "config": { + "configName": "Имя конфигурации", + "environmentVariable": "Переменная среды", + "currentValue": "Текущее значение", + "configurationFile": "Файл конфигурации", + "exportToml": "Экспорт конфигурации (TOML)", + "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", + "exportFailed": "Не удалось скопировать конфигурацию", + "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", + "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях." } }, "activity": { @@ -497,7 +534,10 @@ "quickScan": "Быстрое сканирование", "fullScan": "Полное сканирование", "serverUptime": "Время работы сервера", - "serverDown": "Оффлайн" + "serverDown": "Оффлайн", + "scanType": "Тип", + "status": "Ошибка сканирования", + "elapsedTime": "Прошедшее время" }, "help": { "title": "Горячие клавиши Navidrome", @@ -510,7 +550,12 @@ "vol_up": "Увеличить громкость", "vol_down": "Уменьшить громкость", "toggle_love": "Добавить / удалить песню из избранного", - "current_song": "Перейти к текущей песне" + "current_song": "Перейти к текущему треку" } + }, + "nowPlaying": { + "title": "Сейчас играет", + "empty": "Ничего не играет", + "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" } } \ No newline at end of file diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 9392706cb..d4b289515 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -26,7 +26,16 @@ "bpm": "BPM", "playDate": "Senast spelad", "channels": "Channels", - "createdAt": "Skapad" + "createdAt": "Skapad", + "grouping": "Gruppering", + "mood": "Stämning", + "participants": "Ytterligare medverkande", + "tags": "Ytterligare taggar", + "mappedTags": "Mappade taggar", + "rawTags": "Omodifierade taggar", + "bitDepth": "Bitdjup", + "sampleRate": "Samplingsfrekvens", + "missing": "Saknade" }, "actions": { "addToQueue": "Lägg till i kön", @@ -35,7 +44,8 @@ "shuffleAll": "Shuffle", "download": "Ladda ner", "playNext": "Spela nästa", - "info": "Mer information" + "info": "Mer information", + "showInPlaylist": "Visa i spellista" } }, "album": { @@ -58,7 +68,15 @@ "originalDate": "Originaldatum", "releaseDate": "Utgivningsdatum", "releases": "Utgåva |||| Utgåvor", - "released": "Utgiven" + "released": "Utgiven", + "recordLabel": "Skivbolag", + "catalogNum": "Katalognummer", + "releaseType": "Typ", + "grouping": "Gruppering", + "media": "Media", + "mood": "Stämning", + "date": "Inspelningsdatum", + "missing": "Saknade" }, "actions": { "playAll": "Spela", @@ -89,7 +107,30 @@ "playCount": "Spelningar", "rating": "Betyg", "genre": "Genre", - "size": "Storlek" + "size": "Storlek", + "role": "Roll", + "missing": "Saknade" + }, + "roles": { + "albumartist": "Albumartist |||| Albumartister", + "artist": "Artist |||| Artister", + "composer": "Kompositör |||| Kompositörer", + "conductor": "Dirigent |||| Dirigenter", + "lyricist": "Textförfattare |||| Textförfattare", + "arranger": "Arrangör |||| Arrangörer", + "producer": "Producent |||| Producenter", + "director": "Inspelningsledare |||| Inspelningsledare", + "engineer": "Ljudtekniker |||| Ljudtekniker", + "mixer": "Mixare |||| Mixare", + "remixer": "Remixare |||| Remixare", + "djmixer": "DJ-mixare |||| DJ-mixare", + "performer": "Utövande artist |||| Utövande artister", + "maincredit": "Albumartister eller Artist |||| Albumartister eller Artister" + }, + "actions": { + "shuffle": "Shuffle", + "radio": "Radio", + "topSongs": "Topplåtar" } }, "user": { @@ -162,11 +203,18 @@ "addNewPlaylist": "Skapa \"%{name}\"", "export": "Exportera", "makePublic": "Gör offentlig", - "makePrivate": "Gör privat" + "makePrivate": "Gör privat", + "saveQueue": "Spara kö till spellista", + "searchOrCreate": "Sök spellista eller skapa ny...", + "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", + "removeFromSelection": "Ta bort från urval", + "removeSymbol": "×" }, "message": { "duplicate_song": "Lägg till dubletter", - "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?" + "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", + "noPlaylistsFound": "Hittade inga spellistor", + "noPlaylists": "Inga spellistor tillgängliga" } }, "radio": { @@ -198,6 +246,22 @@ "createdAt": "Skapad", "downloadable": "Tillåt nedladdning?" } + }, + "missing": { + "name": "Saknad fil |||| Saknade filer", + "fields": { + "path": "Sökväg", + "size": "Storlek", + "updatedAt": "Försvann" + }, + "actions": { + "remove": "Radera", + "remove_all": "Radera alla" + }, + "notifications": { + "removed": "Saknade fil(er) borttagna" + }, + "empty": "Inga saknade filer" } }, "ra": { @@ -375,7 +439,13 @@ "shareSuccess": "URL kopierades till urklipp: %{url}", "shareFailure": "Fel vid kopiering av URL %{url} till urklipp", "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter" + "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", + "remove_missing_title": "Ta bort saknade filer", + "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_all_missing_title": "Ta bort alla saknade filer", + "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "noSimilarSongsFound": "Hittade inga liknande låtar", + "noTopSongsFound": "Hittade inga topplåtar" }, "menu": { "library": "Bibliotek", @@ -441,6 +511,21 @@ "disabled": "Inaktiverad", "waiting": "Väntar" } + }, + "tabs": { + "about": "Om", + "config": "Inställningar" + }, + "config": { + "configName": "Inställningsnamn", + "environmentVariable": "Miljövariabel", + "currentValue": "Nuvarande värde", + "configurationFile": "Inställningsfil", + "exportToml": "Exportera inställningar (TOML)", + "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", + "exportFailed": "Kopiering av inställningarna misslyckades", + "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", + "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner" } }, "activity": { @@ -449,7 +534,10 @@ "quickScan": "Snabbscan", "fullScan": "Komplett scan", "serverUptime": "Serverdrifttid", - "serverDown": "OFFLINE" + "serverDown": "OFFLINE", + "scanType": "Typ", + "status": "Fel vid scanning", + "elapsedTime": "Spelad tid" }, "help": { "title": "Navidrome kortkommandon", @@ -464,5 +552,10 @@ "toggle_love": "Lägg till låt i favoriter", "current_song": "Hoppa till nuvarande låt" } + }, + "nowPlaying": { + "title": "Spelas nu", + "empty": "Inget spelas", + "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } } \ No newline at end of file diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index cb1a0262d..c75451d41 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -34,7 +34,8 @@ "mappedTags": "Eşlenen etiketler", "rawTags": "Ham etiketler", "bitDepth": "Bit derinliği", - "sampleRate": "Örnekleme Oranı" + "sampleRate": "Örnekleme Oranı", + "missing": "Eksik" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -43,7 +44,8 @@ "shuffleAll": "Tümünü karıştır", "download": "İndir", "playNext": "Dinlenenden Sonra Oynat", - "info": "Bilgiler" + "info": "Bilgiler", + "showInPlaylist": "Çalma Listesinde Göster" } }, "album": { @@ -73,7 +75,8 @@ "grouping": "Gruplama", "media": "Medya", "mood": "Mod", - "date": "Kayıt Tarihi" + "date": "Kayıt Tarihi", + "missing": "Eksik" }, "actions": { "playAll": "Oynat", @@ -105,7 +108,8 @@ "rating": "Derecelendirme", "genre": "Tür", "size": "Boyut", - "role": "Rol" + "role": "Rol", + "missing": "Eksik" }, "roles": { "albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı", @@ -120,7 +124,13 @@ "mixer": "Mikser |||| Mikser", "remixer": "Remiks |||| Remiks", "djmixer": "DJ Mikseri |||| DJ Mikseri", - "performer": "Sanatçı |||| Sanatçı" + "performer": "Sanatçı |||| Sanatçı", + "maincredit": "Albüm Sanatçısı veya Sanatçı |||| Albüm Sanatçısı veya Sanatçılar" + }, + "actions": { + "shuffle": "Karıştır", + "radio": "Radyo", + "topSongs": "En İyi Şarkılar" } }, "user": { @@ -193,11 +203,18 @@ "addNewPlaylist": "Oluştur \"%{name}\"", "export": "Aktar", "makePublic": "Herkese Açık Yap", - "makePrivate": "Özel Yap" + "makePrivate": "Özel Yap", + "saveQueue": "Kuyruktakileri Çalma Listesine Kaydet", + "searchOrCreate": "Çalma listelerini arayın veya yenisini oluşturmak için yazın...", + "pressEnterToCreate": "Yeni çalma listesi oluşturmak için Enter'a basın", + "removeFromSelection": "Seçimden kaldır", + "removeSymbol": "×" }, "message": { "duplicate_song": "Yinelenen şarkıları ekle", - "song_exist": "Seçili müziklerin bazıları eklemek istediğin çalma listesinde mevcut. Yine de eklemek ister misin ?" + "song_exist": "Seçili müziklerin bazıları eklemek istediğin çalma listesinde mevcut. Yine de eklemek ister misin ?", + "noPlaylistsFound": "Hiç çalma listesi bulunamadı", + "noPlaylists": "Çalma listesi mevcut değil" } }, "radio": { @@ -238,7 +255,8 @@ "updatedAt": "Kaybolma" }, "actions": { - "remove": "Kaldır" + "remove": "Kaldır", + "remove_all": "Tümünü Kaldır" }, "notifications": { "removed": "Eksik dosya(lar) kaldırıldı" @@ -423,7 +441,11 @@ "downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin", "shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter", "remove_missing_title": "Eksik dosyaları kaldır", - "remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır." + "remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.", + "remove_all_missing_title": "Tüm eksik dosyaları kaldırın", + "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.", + "noSimilarSongsFound": "Benzer şarkı bulunamadı", + "noTopSongsFound": "En iyi şarkı listesi boş" }, "menu": { "library": "Kütüphane", @@ -489,6 +511,21 @@ "disabled": "Pasif", "waiting": "Bekle" } + }, + "tabs": { + "about": "Hakkında", + "config": "Yapılandırma" + }, + "config": { + "configName": "Yapılandırma Adı", + "environmentVariable": "Çevre Değişkeni", + "currentValue": "Güncel Değer", + "configurationFile": "Yapılandırma Dosyası", + "exportToml": "Yapılandırmayı Dışa Aktar (TOML)", + "exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı", + "exportFailed": "Yapılandırma kopyalanamadı", + "devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)", + "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir" } }, "activity": { @@ -497,7 +534,10 @@ "quickScan": "Hızlı Tarama", "fullScan": "Tam Tarama", "serverUptime": "Sunucu Çalışma Süresi", - "serverDown": "ÇEVRİMDIŞI" + "serverDown": "ÇEVRİMDIŞI", + "scanType": "Tür", + "status": "Tarama Hatası", + "elapsedTime": "Geçen Süre" }, "help": { "title": "Navidrome Kısayolları", @@ -512,5 +552,10 @@ "toggle_love": "Bu şarkıyı favorilere ekle", "current_song": "Mevcut Şarkıya Git" } + }, + "nowPlaying": { + "title": "Şu An Çalıyor", + "empty": "Çalan şarkı yok", + "minutesAgo": "%{smart_count} dakika önce" } } \ No newline at end of file diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index d0c4713e3..a8be902c9 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -32,7 +32,10 @@ "participants": "Додаткові вчасники", "tags": "Додаткові теги", "mappedTags": "Зіставлені теги", - "rawTags": "Вихідні теги" + "rawTags": "Вихідні теги", + "bitDepth": "Глибина розрядності", + "sampleRate": "Частота дискретизації", + "missing": "Поле відсутнє" }, "actions": { "addToQueue": "Прослухати пізніше", @@ -70,7 +73,9 @@ "releaseType": "Тип", "grouping": "Групування", "media": "Медіа", - "mood": "Настрій" + "mood": "Настрій", + "date": "Дата запису", + "missing": "Поле відсутнє" }, "actions": { "playAll": "Прослухати", @@ -102,7 +107,8 @@ "rating": "Рейтинг", "genre": "Жанр", "size": "Розмір", - "role": "Роль" + "role": "Роль", + "missing": "Поле відсутнє" }, "roles": { "albumartist": "Виконавець альбому |||| Виконавці альбому", @@ -190,7 +196,8 @@ "addNewPlaylist": "Створити \"%{name}\"", "export": "Експортувати", "makePublic": "Зробити публічним", - "makePrivate": "Зробити приватним" + "makePrivate": "Зробити приватним", + "saveQueue": "Зберегти чергу до плейлиста" }, "message": { "duplicate_song": "Додати повторювані пісні", @@ -235,11 +242,13 @@ "updatedAt": "Зник" }, "actions": { - "remove": "Видалити" + "remove": "Видалити", + "remove_all": "Вилучити всі" }, "notifications": { "removed": "Видалено зниклі файл(и)" - } + }, + "empty": "Немає відсутніх файлів" } }, "ra": { @@ -419,7 +428,9 @@ "downloadDialogTitle": "Завантаження %{resource} '%{name}' (%{size})", "shareCopyToClipboard": "Скопіювати в буфер: Ctrl+C, Enter", "remove_missing_title": "Видалити зниклі файли", - "remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги." + "remove_missing_content": "Ви впевнені, що хочете видалити вибрані відсутні файли з бази даних? Це назавжди видалить усі посилання на них, включаючи кількість прослуховувань та рейтинги.", + "remove_all_missing_title": "Видалити всі відсутні файли", + "remove_all_missing_content": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами." }, "menu": { "library": "Бібліотека", @@ -493,7 +504,10 @@ "quickScan": "Швидке сканування", "fullScan": "Повне сканування", "serverUptime": "Час роботи", - "serverDown": "Оффлайн" + "serverDown": "Оффлайн", + "scanType": "Тип", + "status": "Помилка сканування", + "elapsedTime": "Пройдений час" }, "help": { "title": "Гарячі клавіші Navidrome", diff --git a/resources/mappings.yaml b/resources/mappings.yaml index 650665c78..f461d889e 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -69,7 +69,7 @@ main: remixer: aliases: [ tpe4, remixer, mixartist, ----:com.apple.itunes:remixer, wm/modifiedby ] albumartist: - aliases: [ tpe2, albumartist, album artist, aart, wm/albumartist ] + aliases: [ tpe2, albumartist, album artist, album_artist, aart, wm/albumartist ] albumartistsort: aliases: [ tso2, txxx:albumartistsort, albumartistsort, soaa, wm/albumartistsortorder ] albumartists: diff --git a/scanner/controller.go b/scanner/controller.go index 36048e6f3..f3fdd593f 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -7,7 +7,6 @@ import ( "sync/atomic" "time" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" @@ -63,13 +62,12 @@ func (s *controller) getScanner() scanner { if conf.Server.DevExternalScanner { return &scannerExternal{} } - return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls, metrics: s.metrics} + return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} } // CallScan starts an in-process scan of the music library. // This is meant to be called from the command line (see cmd/scan.go). -func CallScan(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, pls core.Playlists, - metrics metrics.Metrics, fullScan bool) (<-chan *ProgressInfo, error) { +func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) { release, err := lockScan(ctx) if err != nil { return nil, err @@ -80,7 +78,7 @@ func CallScan(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, p progress := make(chan *ProgressInfo, 100) go func() { defer close(progress) - scanner := &scannerImpl{ds: ds, cw: cw, pls: pls, metrics: metrics} + scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} scanner.scanAll(ctx, fullScan, progress) }() return progress, nil @@ -98,6 +96,7 @@ type ProgressInfo struct { ChangesDetected bool Warning string Error string + ForceUpdate bool } type scanner interface { @@ -178,20 +177,14 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) { } func (s *controller) getCounters(ctx context.Context) (int64, int64, error) { - count, err := s.ds.MediaFile(ctx).CountAll() + libs, err := s.ds.Library(ctx).GetAll() if err != nil { - return 0, 0, fmt.Errorf("media file count: %w", err) + return 0, 0, fmt.Errorf("library count: %w", err) } - folderCount, err := s.ds.Folder(ctx).CountAll( - model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Gt{"num_audio_files": 0}, - squirrel.Eq{"missing": false}, - }, - }, - ) - if err != nil { - return 0, 0, fmt.Errorf("folder count: %w", err) + var count, folderCount int64 + for _, l := range libs { + count += int64(l.TotalSongs) + folderCount += int64(l.TotalFolders) } return count, folderCount, nil } @@ -229,9 +222,11 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin } // Send the final scan status event, with totals if count, folderCount, err := s.getCounters(ctx); err != nil { + s.metrics.WriteAfterScanMetrics(ctx, false) return scanWarnings, err } else { scanType, elapsed, lastErr := s.getScanInfo(ctx) + s.metrics.WriteAfterScanMetrics(ctx, true) s.sendMessage(ctx, &events.ScanStatus{ Scanning: false, Count: count, @@ -292,7 +287,7 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres ScanType: scanType, ElapsedTime: elapsed, } - if s.limiter != nil { + if s.limiter != nil && !p.ForceUpdate { s.limiter.Do(func() { s.sendMessage(ctx, status) }) } else { s.sendMessage(ctx, status) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go new file mode 100644 index 000000000..fc68cb561 --- /dev/null +++ b/scanner/folder_entry.go @@ -0,0 +1,120 @@ +package scanner + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "io/fs" + "maps" + "slices" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/chrono" +) + +func newFolderEntry(job *scanJob, path string) *folderEntry { + id := model.FolderID(job.lib, path) + info := job.popLastUpdate(id) + f := &folderEntry{ + id: id, + job: job, + path: path, + audioFiles: make(map[string]fs.DirEntry), + imageFiles: make(map[string]fs.DirEntry), + albumIDMap: make(map[string]string), + updTime: info.UpdatedAt, + prevHash: info.Hash, + } + return f +} + +type folderEntry struct { + job *scanJob + elapsed chrono.Meter + path string // Full path + id string // DB ID + modTime time.Time // From FS + updTime time.Time // from DB + audioFiles map[string]fs.DirEntry + imageFiles map[string]fs.DirEntry + numPlaylists int + numSubFolders int + imagesUpdatedAt time.Time + prevHash string // Previous hash from DB + tracks model.MediaFiles + albums model.Albums + albumIDMap map[string]string + artists model.Artists + tags model.TagList + missingTracks []*model.MediaFile +} + +func (f *folderEntry) hasNoFiles() bool { + return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 +} + +func (f *folderEntry) isEmpty() bool { + return f.hasNoFiles() && f.numSubFolders == 0 +} + +func (f *folderEntry) isNew() bool { + return f.updTime.IsZero() +} + +func (f *folderEntry) isOutdated() bool { + if f.job.lib.FullScanInProgress && f.updTime.Before(f.job.lib.LastScanStartedAt) { + return true + } + return f.prevHash != f.hash() +} + +func (f *folderEntry) toFolder() *model.Folder { + folder := model.NewFolder(f.job.lib, f.path) + folder.NumAudioFiles = len(f.audioFiles) + if core.InPlaylistsPath(*folder) { + folder.NumPlaylists = f.numPlaylists + } + folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) + folder.ImagesUpdatedAt = f.imagesUpdatedAt + folder.Hash = f.hash() + return folder +} + +func (f *folderEntry) hash() string { + h := md5.New() + _, _ = fmt.Fprintf( + h, + "%s:%d:%d:%s", + f.modTime.UTC(), + f.numPlaylists, + f.numSubFolders, + f.imagesUpdatedAt.UTC(), + ) + + // Sort the keys of audio and image files to ensure consistent hashing + audioKeys := slices.Collect(maps.Keys(f.audioFiles)) + slices.Sort(audioKeys) + imageKeys := slices.Collect(maps.Keys(f.imageFiles)) + slices.Sort(imageKeys) + + // Include audio files with their size and modtime + for _, key := range audioKeys { + _, _ = io.WriteString(h, key) + if info, err := f.audioFiles[key].Info(); err == nil { + _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) + } + } + + // Include image files with their size and modtime + for _, key := range imageKeys { + _, _ = io.WriteString(h, key) + if info, err := f.imageFiles[key].Info(); err == nil { + _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) + } + } + + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go new file mode 100644 index 000000000..c6d1b2ce4 --- /dev/null +++ b/scanner/folder_entry_test.go @@ -0,0 +1,544 @@ +package scanner + +import ( + "io/fs" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("folder_entry", func() { + var ( + lib model.Library + job *scanJob + path string + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + lib = model.Library{ + ID: 500, + Path: "/music", + LastScanStartedAt: time.Now().Add(-1 * time.Hour), + FullScanInProgress: false, + } + job = &scanJob{ + lib: lib, + lastUpdates: make(map[string]model.FolderUpdateInfo), + } + path = "test/folder" + }) + + Describe("newFolderEntry", func() { + It("creates a new folder entry with correct initialization", func() { + folderID := model.FolderID(lib, path) + updateInfo := model.FolderUpdateInfo{ + UpdatedAt: time.Now().Add(-30 * time.Minute), + Hash: "previous-hash", + } + job.lastUpdates[folderID] = updateInfo + + entry := newFolderEntry(job, path) + + Expect(entry.id).To(Equal(folderID)) + Expect(entry.job).To(Equal(job)) + Expect(entry.path).To(Equal(path)) + Expect(entry.audioFiles).To(BeEmpty()) + Expect(entry.imageFiles).To(BeEmpty()) + Expect(entry.albumIDMap).To(BeEmpty()) + Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) + Expect(entry.prevHash).To(Equal(updateInfo.Hash)) + }) + + It("creates a new folder entry with zero time when no previous update exists", func() { + entry := newFolderEntry(job, path) + + Expect(entry.updTime).To(BeZero()) + Expect(entry.prevHash).To(BeEmpty()) + }) + + It("removes the lastUpdate from the job after popping", func() { + folderID := model.FolderID(lib, path) + updateInfo := model.FolderUpdateInfo{ + UpdatedAt: time.Now().Add(-30 * time.Minute), + Hash: "previous-hash", + } + job.lastUpdates[folderID] = updateInfo + + newFolderEntry(job, path) + + Expect(job.lastUpdates).ToNot(HaveKey(folderID)) + }) + }) + + Describe("folderEntry", func() { + var entry *folderEntry + + BeforeEach(func() { + entry = newFolderEntry(job, path) + }) + + Describe("hasNoFiles", func() { + It("returns true when folder has no files or subfolders", func() { + Expect(entry.hasNoFiles()).To(BeTrue()) + }) + + It("returns false when folder has audio files", func() { + entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} + Expect(entry.hasNoFiles()).To(BeFalse()) + }) + + It("returns false when folder has image files", func() { + entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} + Expect(entry.hasNoFiles()).To(BeFalse()) + }) + + It("returns false when folder has playlists", func() { + entry.numPlaylists = 1 + Expect(entry.hasNoFiles()).To(BeFalse()) + }) + + It("ignores subfolders when checking for no files", func() { + entry.numSubFolders = 1 + Expect(entry.hasNoFiles()).To(BeTrue()) + }) + + It("returns false when folder has multiple types of content", func() { + entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} + entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} + entry.numPlaylists = 2 + entry.numSubFolders = 3 + Expect(entry.hasNoFiles()).To(BeFalse()) + }) + }) + + Describe("isEmpty", func() { + It("returns true when folder has no files or subfolders", func() { + Expect(entry.isEmpty()).To(BeTrue()) + }) + It("returns false when folder has audio files", func() { + entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} + Expect(entry.isEmpty()).To(BeFalse()) + }) + It("returns false when folder has subfolders", func() { + entry.numSubFolders = 1 + Expect(entry.isEmpty()).To(BeFalse()) + }) + }) + + Describe("isNew", func() { + It("returns true when updTime is zero", func() { + entry.updTime = time.Time{} + Expect(entry.isNew()).To(BeTrue()) + }) + + It("returns false when updTime is not zero", func() { + entry.updTime = time.Now() + Expect(entry.isNew()).To(BeFalse()) + }) + }) + + Describe("toFolder", func() { + BeforeEach(func() { + entry.audioFiles = map[string]fs.DirEntry{ + "song1.mp3": &fakeDirEntry{name: "song1.mp3"}, + "song2.mp3": &fakeDirEntry{name: "song2.mp3"}, + } + entry.imageFiles = map[string]fs.DirEntry{ + "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, + "folder.png": &fakeDirEntry{name: "folder.png"}, + } + entry.numPlaylists = 3 + entry.imagesUpdatedAt = time.Now() + }) + + It("converts folder entry to model.Folder correctly", func() { + folder := entry.toFolder() + + Expect(folder.LibraryID).To(Equal(lib.ID)) + Expect(folder.ID).To(Equal(entry.id)) + Expect(folder.NumAudioFiles).To(Equal(2)) + Expect(folder.ImageFiles).To(ConsistOf("cover.jpg", "folder.png")) + Expect(folder.ImagesUpdatedAt).To(Equal(entry.imagesUpdatedAt)) + Expect(folder.Hash).To(Equal(entry.hash())) + }) + + It("sets NumPlaylists when folder is in playlists path", func() { + // Mock InPlaylistsPath to return true by setting empty PlaylistsPath + originalPath := conf.Server.PlaylistsPath + conf.Server.PlaylistsPath = "" + DeferCleanup(func() { conf.Server.PlaylistsPath = originalPath }) + + folder := entry.toFolder() + Expect(folder.NumPlaylists).To(Equal(3)) + }) + + It("does not set NumPlaylists when folder is not in playlists path", func() { + // Mock InPlaylistsPath to return false by setting a different path + originalPath := conf.Server.PlaylistsPath + conf.Server.PlaylistsPath = "different/path" + DeferCleanup(func() { conf.Server.PlaylistsPath = originalPath }) + + folder := entry.toFolder() + Expect(folder.NumPlaylists).To(BeZero()) + }) + }) + + Describe("hash", func() { + BeforeEach(func() { + entry.modTime = time.Date(2023, 1, 15, 12, 0, 0, 0, time.UTC) + entry.imagesUpdatedAt = time.Date(2023, 1, 16, 14, 30, 0, 0, time.UTC) + }) + + It("produces deterministic hash for same content", func() { + entry.audioFiles = map[string]fs.DirEntry{ + "b.mp3": &fakeDirEntry{name: "b.mp3"}, + "a.mp3": &fakeDirEntry{name: "a.mp3"}, + } + entry.imageFiles = map[string]fs.DirEntry{ + "z.jpg": &fakeDirEntry{name: "z.jpg"}, + "x.png": &fakeDirEntry{name: "x.png"}, + } + entry.numPlaylists = 2 + entry.numSubFolders = 3 + + hash1 := entry.hash() + + // Reverse order of maps + entry.audioFiles = map[string]fs.DirEntry{ + "a.mp3": &fakeDirEntry{name: "a.mp3"}, + "b.mp3": &fakeDirEntry{name: "b.mp3"}, + } + entry.imageFiles = map[string]fs.DirEntry{ + "x.png": &fakeDirEntry{name: "x.png"}, + "z.jpg": &fakeDirEntry{name: "z.jpg"}, + } + + hash2 := entry.hash() + Expect(hash1).To(Equal(hash2)) + }) + + It("produces different hash when audio files change", func() { + entry.audioFiles = map[string]fs.DirEntry{ + "song1.mp3": &fakeDirEntry{name: "song1.mp3"}, + } + hash1 := entry.hash() + + entry.audioFiles["song2.mp3"] = &fakeDirEntry{name: "song2.mp3"} + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when image files change", func() { + entry.imageFiles = map[string]fs.DirEntry{ + "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, + } + hash1 := entry.hash() + + entry.imageFiles["folder.png"] = &fakeDirEntry{name: "folder.png"} + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when modification time changes", func() { + hash1 := entry.hash() + + entry.modTime = entry.modTime.Add(1 * time.Hour) + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when playlist count changes", func() { + hash1 := entry.hash() + + entry.numPlaylists = 5 + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when subfolder count changes", func() { + hash1 := entry.hash() + + entry.numSubFolders = 3 + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when images updated time changes", func() { + hash1 := entry.hash() + + entry.imagesUpdatedAt = entry.imagesUpdatedAt.Add(2 * time.Hour) + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when audio file size changes", func() { + entry.audioFiles["test.mp3"] = &fakeDirEntry{ + name: "test.mp3", + fileInfo: &fakeFileInfo{ + name: "test.mp3", + size: 1000, + modTime: time.Now(), + }, + } + hash1 := entry.hash() + + entry.audioFiles["test.mp3"] = &fakeDirEntry{ + name: "test.mp3", + fileInfo: &fakeFileInfo{ + name: "test.mp3", + size: 2000, // Different size + modTime: time.Now(), + }, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when audio file modification time changes", func() { + baseTime := time.Now() + entry.audioFiles["test.mp3"] = &fakeDirEntry{ + name: "test.mp3", + fileInfo: &fakeFileInfo{ + name: "test.mp3", + size: 1000, + modTime: baseTime, + }, + } + hash1 := entry.hash() + + entry.audioFiles["test.mp3"] = &fakeDirEntry{ + name: "test.mp3", + fileInfo: &fakeFileInfo{ + name: "test.mp3", + size: 1000, + modTime: baseTime.Add(1 * time.Hour), // Different modtime + }, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when image file size changes", func() { + entry.imageFiles["cover.jpg"] = &fakeDirEntry{ + name: "cover.jpg", + fileInfo: &fakeFileInfo{ + name: "cover.jpg", + size: 5000, + modTime: time.Now(), + }, + } + hash1 := entry.hash() + + entry.imageFiles["cover.jpg"] = &fakeDirEntry{ + name: "cover.jpg", + fileInfo: &fakeFileInfo{ + name: "cover.jpg", + size: 6000, // Different size + modTime: time.Now(), + }, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when image file modification time changes", func() { + baseTime := time.Now() + entry.imageFiles["cover.jpg"] = &fakeDirEntry{ + name: "cover.jpg", + fileInfo: &fakeFileInfo{ + name: "cover.jpg", + size: 5000, + modTime: baseTime, + }, + } + hash1 := entry.hash() + + entry.imageFiles["cover.jpg"] = &fakeDirEntry{ + name: "cover.jpg", + fileInfo: &fakeFileInfo{ + name: "cover.jpg", + size: 5000, + modTime: baseTime.Add(1 * time.Hour), // Different modtime + }, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces valid hex-encoded hash", func() { + hash := entry.hash() + Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters + Expect(hash).To(MatchRegexp("^[a-f0-9]{32}$")) + }) + }) + + Describe("isOutdated", func() { + BeforeEach(func() { + entry.prevHash = entry.hash() + }) + + Context("when full scan is in progress", func() { + BeforeEach(func() { + entry.job.lib.FullScanInProgress = true + entry.job.lib.LastScanStartedAt = time.Now() + }) + + It("returns true when updTime is before LastScanStartedAt", func() { + entry.updTime = entry.job.lib.LastScanStartedAt.Add(-1 * time.Hour) + Expect(entry.isOutdated()).To(BeTrue()) + }) + + It("returns false when updTime is after LastScanStartedAt", func() { + entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) + Expect(entry.isOutdated()).To(BeFalse()) + }) + + It("returns false when updTime equals LastScanStartedAt", func() { + entry.updTime = entry.job.lib.LastScanStartedAt + Expect(entry.isOutdated()).To(BeFalse()) + }) + }) + + Context("when full scan is not in progress", func() { + BeforeEach(func() { + entry.job.lib.FullScanInProgress = false + }) + + It("returns false when hash hasn't changed", func() { + Expect(entry.isOutdated()).To(BeFalse()) + }) + + It("returns true when hash has changed", func() { + entry.numPlaylists = 10 // Change something to change the hash + Expect(entry.isOutdated()).To(BeTrue()) + }) + + It("returns true when prevHash is empty", func() { + entry.prevHash = "" + Expect(entry.isOutdated()).To(BeTrue()) + }) + }) + + Context("priority between conditions", func() { + BeforeEach(func() { + entry.job.lib.FullScanInProgress = true + entry.job.lib.LastScanStartedAt = time.Now() + entry.updTime = entry.job.lib.LastScanStartedAt.Add(-1 * time.Hour) + }) + + It("returns true for full scan condition even when hash hasn't changed", func() { + // Hash is the same but full scan condition should take priority + Expect(entry.isOutdated()).To(BeTrue()) + }) + + It("returns true when full scan condition is not met but hash changed", func() { + entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) + entry.numPlaylists = 10 // Change hash + Expect(entry.isOutdated()).To(BeTrue()) + }) + }) + }) + }) + + Describe("integration scenarios", func() { + It("handles complete folder lifecycle", func() { + // Create new folder entry + entry := newFolderEntry(job, "music/rock/album") + + // Initially new and has no files + Expect(entry.isNew()).To(BeTrue()) + Expect(entry.hasNoFiles()).To(BeTrue()) + + // Add some files + entry.audioFiles["track1.mp3"] = &fakeDirEntry{name: "track1.mp3"} + entry.audioFiles["track2.mp3"] = &fakeDirEntry{name: "track2.mp3"} + entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} + entry.numSubFolders = 1 + entry.modTime = time.Now() + entry.imagesUpdatedAt = time.Now() + + // No longer empty + Expect(entry.hasNoFiles()).To(BeFalse()) + + // Set previous hash to current hash (simulating it's been saved) + entry.prevHash = entry.hash() + entry.updTime = time.Now() + + // Should not be new or outdated + Expect(entry.isNew()).To(BeFalse()) + Expect(entry.isOutdated()).To(BeFalse()) + + // Convert to model folder + folder := entry.toFolder() + Expect(folder.NumAudioFiles).To(Equal(2)) + Expect(folder.ImageFiles).To(HaveLen(1)) + Expect(folder.Hash).To(Equal(entry.hash())) + + // Modify folder and verify it becomes outdated + entry.audioFiles["track3.mp3"] = &fakeDirEntry{name: "track3.mp3"} + Expect(entry.isOutdated()).To(BeTrue()) + }) + }) +}) + +// fakeDirEntry implements fs.DirEntry for testing +type fakeDirEntry struct { + name string + isDir bool + typ fs.FileMode + fileInfo fs.FileInfo +} + +func (f *fakeDirEntry) Name() string { + return f.name +} + +func (f *fakeDirEntry) IsDir() bool { + return f.isDir +} + +func (f *fakeDirEntry) Type() fs.FileMode { + return f.typ +} + +func (f *fakeDirEntry) Info() (fs.FileInfo, error) { + if f.fileInfo != nil { + return f.fileInfo, nil + } + return &fakeFileInfo{ + name: f.name, + isDir: f.isDir, + mode: f.typ, + }, nil +} + +// fakeFileInfo implements fs.FileInfo for testing +type fakeFileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool +} + +func (f *fakeFileInfo) Name() string { return f.name } +func (f *fakeFileInfo) Size() int64 { return f.size } +func (f *fakeFileInfo) Mode() fs.FileMode { return f.mode } +func (f *fakeFileInfo) ModTime() time.Time { return f.modTime } +func (f *fakeFileInfo) IsDir() bool { return f.isDir } +func (f *fakeFileInfo) Sys() any { return nil } diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index ae0d906de..8397d6924 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -62,7 +62,7 @@ type scanJob struct { lib model.Library fs storage.MusicFS cw artwork.CacheWarmer - lastUpdates map[string]time.Time + lastUpdates map[string]model.FolderUpdateInfo lock sync.Mutex numFolders atomic.Int64 } @@ -91,7 +91,7 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, }, nil } -func (j *scanJob) popLastUpdate(folderID string) time.Time { +func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { j.lock.Lock() defer j.lock.Unlock() @@ -164,7 +164,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) continue } - log.Trace(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) + log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) } totalChanged++ folder.elapsed.Stop() @@ -345,7 +345,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later for i := range entry.artists { err = artistRepo.Put(&entry.artists[i], "name", - "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text") + "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "updated_at") if err != nil { log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) return err @@ -439,7 +439,7 @@ func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { logCall := log.Info - if entry.hasNoFiles() { + if entry.isEmpty() { logCall = log.Trace } logCall(p.ctx, "Scanner: Completed processing folder", diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go index 352f92c34..6f56f6a52 100644 --- a/scanner/phase_2_missing_tracks.go +++ b/scanner/phase_2_missing_tracks.go @@ -6,6 +6,8 @@ import ( "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -182,7 +184,35 @@ func (p *phaseMissingTracks) finalize(err error) error { if matched > 0 { log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err) } + if err != nil { + return err + } + + // Check if we should purge missing items + if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) { + if err = p.purgeMissing(); err != nil { + log.Error(p.ctx, "Scanner: Error purging missing items", err) + } + } + return err } +func (p *phaseMissingTracks) purgeMissing() error { + deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing() + if err != nil { + return fmt.Errorf("error deleting missing files: %w", err) + } + + if deletedCount > 0 { + log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount) + // Set changesDetected to true so that garbage collection will run at the end of the scan process + p.state.changesDetected.Store(true) + } else { + log.Debug(p.ctx, "Scanner: No missing items to purge") + } + + return nil +} + var _ phase[*missingTracks] = (*phaseMissingTracks)(nil) diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go index 2cd686604..5dd6cc679 100644 --- a/scanner/phase_2_missing_tracks_test.go +++ b/scanner/phase_2_missing_tracks_test.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -222,4 +224,66 @@ var _ = Describe("phaseMissingTracks", func() { Expect(state.changesDetected.Load()).To(BeFalse()) }) }) + + Describe("finalize", func() { + It("should return nil if no error", func() { + err := phase.finalize(nil) + Expect(err).To(BeNil()) + Expect(state.changesDetected.Load()).To(BeFalse()) + }) + + It("should return the error if provided", func() { + err := phase.finalize(context.DeadlineExceeded) + Expect(err).To(Equal(context.DeadlineExceeded)) + Expect(state.changesDetected.Load()).To(BeFalse()) + }) + + When("PurgeMissing is 'always'", func() { + BeforeEach(func() { + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways + mr.CountAllValue = 3 + mr.DeleteAllMissingValue = 3 + }) + It("should purge missing files", func() { + Expect(state.changesDetected.Load()).To(BeFalse()) + err := phase.finalize(nil) + Expect(err).To(BeNil()) + Expect(state.changesDetected.Load()).To(BeTrue()) + }) + }) + + When("PurgeMissing is 'full'", func() { + BeforeEach(func() { + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull + mr.CountAllValue = 2 + mr.DeleteAllMissingValue = 2 + }) + It("should not purge missing files if not a full scan", func() { + state.fullScan = false + err := phase.finalize(nil) + Expect(err).To(BeNil()) + Expect(state.changesDetected.Load()).To(BeFalse()) + }) + It("should purge missing files if full scan", func() { + Expect(state.changesDetected.Load()).To(BeFalse()) + state.fullScan = true + err := phase.finalize(nil) + Expect(err).To(BeNil()) + Expect(state.changesDetected.Load()).To(BeTrue()) + }) + }) + + When("PurgeMissing is 'never'", func() { + BeforeEach(func() { + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever + mr.CountAllValue = 1 + mr.DeleteAllMissingValue = 1 + }) + It("should not purge missing files", func() { + err := phase.finalize(nil) + Expect(err).To(BeNil()) + Expect(state.changesDetected.Load()).To(BeFalse()) + }) + }) + }) }) diff --git a/scanner/scanner.go b/scanner/scanner.go index 21de5f956..7ddc78b17 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,18 +11,16 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/chain" + "github.com/navidrome/navidrome/utils/run" ) type scannerImpl struct { - ds model.DataStore - cw artwork.CacheWarmer - pls core.Playlists - metrics metrics.Metrics + ds model.DataStore + cw artwork.CacheWarmer + pls core.Playlists } // scanState holds the state of an in-progress scan, to be passed to the various phases @@ -47,14 +45,25 @@ func (s *scanState) sendError(err error) { } func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) { - state := scanState{progress: progress, fullScan: fullScan} + startTime := time.Now() + + state := scanState{ + progress: progress, + fullScan: fullScan, + changesDetected: atomic.Bool{}, + } + + // Set changesDetected to true for full scans to ensure all maintenance operations run + if fullScan { + state.changesDetected.Store(true) + } + libs, err := s.ds.Library(ctx).GetAll() if err != nil { state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) return } - startTime := time.Now() log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs)) // Store scan type and start time @@ -77,7 +86,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< } } - err = chain.RunSequentially( + err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw, libs)), @@ -85,7 +94,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), // Phases 3 and 4 can be run in parallel - chain.RunParallel( + run.Parallel( // Phase 3: Refresh all new/changed albums and update artists runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds, libs)), @@ -102,7 +111,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< s.runRefreshStats(ctx, &state), // Update last_scan_completed_at for all libraries - s.runUpdateLibraries(ctx, libs), + s.runUpdateLibraries(ctx, libs, &state), // Optimize DB s.runOptimize(ctx), @@ -111,7 +120,6 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error()) state.sendError(err) - s.metrics.WriteAfterScanMetrics(ctx, false) return } @@ -121,12 +129,12 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan< state.sendProgress(&ProgressInfo{ChangesDetected: true}) } - s.metrics.WriteAfterScanMetrics(ctx, err == nil) log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime)) } func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error { return func() error { + state.sendProgress(&ProgressInfo{ForceUpdate: true}) return s.ds.WithTx(func(tx model.DataStore) error { if state.changesDetected.Load() { start := time.Now() @@ -151,7 +159,7 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun return nil } start := time.Now() - stats, err := s.ds.Artist(ctx).RefreshStats() + stats, err := s.ds.Artist(ctx).RefreshStats(state.fullScan) if err != nil { log.Error(ctx, "Scanner: Error refreshing artists stats", err) return fmt.Errorf("refreshing artists stats: %w", err) @@ -178,8 +186,9 @@ func (s *scannerImpl) runOptimize(ctx context.Context) func() error { } } -func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Libraries) func() error { +func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Libraries, state *scanState) func() error { return func() error { + start := time.Now() return s.ds.WithTx(func(tx model.DataStore) error { for _, lib := range libs { err := tx.Library(ctx).ScanEnd(lib.ID) @@ -197,7 +206,17 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, libs model.Librari log.Error(ctx, "Scanner: Error updating album PID conf", err) return fmt.Errorf("updating album PID conf: %w", err) } + if state.changesDetected.Load() { + log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) + if err := tx.Library(ctx).RefreshStats(lib.ID); err != nil { + log.Error(ctx, "Scanner: Error refreshing library stats", "lib", lib.Name, err) + return fmt.Errorf("refreshing library stats: %w", err) + } + } else { + log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name) + } } + log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(libs)) return nil }, "scanner: update libraries") } diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 33c78fe7d..6bb74997f 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" @@ -47,14 +49,15 @@ var _ = Describe("Scanner", Ordered, func() { } BeforeAll(func() { + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) tmpDir := GinkgoT().TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") log.Warn("Using DB at " + conf.Server.DbPath) //conf.Server.DbPath = ":memory:" + db.Db().SetMaxOpenConns(1) }) BeforeEach(func() { - ctx = context.Background() db.Init(ctx) DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) @@ -501,6 +504,206 @@ var _ = Describe("Scanner", Ordered, func() { Expect(aa[0].MbzArtistID).To(Equal(beatlesMBID)) Expect(aa[0].SortArtistName).To(Equal("Beatles, The")) }) + + Context("When PurgeMissing is configured", func() { + When("PurgeMissing is set to 'never'", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever + }) + + It("should mark files as missing but not delete them", func() { + By("Running initial scan") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Removing a file") + fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + + By("Running another scan") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Checking files are marked as missing but not deleted") + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeTrue()) + }) + }) + + When("PurgeMissing is set to 'always'", func() { + BeforeEach(func() { + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways + }) + + It("should purge missing files on any scan", func() { + By("Running initial scan") + Expect(runScanner(ctx, false)).To(Succeed()) + + By("Removing a file") + fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + + By("Running an incremental scan") + Expect(runScanner(ctx, false)).To(Succeed()) + + By("Checking missing files are deleted") + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + + _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + When("PurgeMissing is set to 'full'", func() { + BeforeEach(func() { + conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull + }) + + It("should not purge missing files on incremental scans", func() { + By("Running initial scan") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Removing a file") + fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + + By("Running an incremental scan") + Expect(runScanner(ctx, false)).To(Succeed()) + + By("Checking files are marked as missing but not deleted") + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(mf.Missing).To(BeTrue()) + }) + + It("should purge missing files only on full scans", func() { + By("Running initial scan") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Removing a file") + fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + + By("Running a full scan") + Expect(runScanner(ctx, true)).To(Succeed()) + + By("Checking missing files are deleted") + count, err := ds.MediaFile(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": true}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + + _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + }) + }) + + Describe("RefreshStats", func() { + var refreshStatsCalls []bool + var fsys storagetest.FakeFS + var help func(...map[string]any) *fstest.MapFile + + BeforeEach(func() { + refreshStatsCalls = nil + + // Create a mock artist repository that tracks RefreshStats calls + originalArtistRepo := ds.RealDS.Artist(ctx) + ds.MockedArtist = &testArtistRepo{ + ArtistRepository: originalArtistRepo, + callTracker: &refreshStatsCalls, + } + + // Create a simple filesystem for testing + help = template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965}) + fsys = createFS(fstest.MapFS{ + "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), + }) + }) + + It("should call RefreshStats with allArtists=true for full scans", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + Expect(refreshStatsCalls).To(HaveLen(1)) + Expect(refreshStatsCalls[0]).To(BeTrue(), "RefreshStats should be called with allArtists=true for full scans") + }) + + It("should call RefreshStats with allArtists=false for incremental scans", func() { + // First do a full scan to set up the data + Expect(runScanner(ctx, true)).To(Succeed()) + + // Reset the tracker to only track the incremental scan + refreshStatsCalls = nil + + // Add a new file to trigger changes detection + fsys.Add("The Beatles/Help!/02 - The Night Before.mp3", help(track(2, "The Night Before"))) + + // Do an incremental scan + Expect(runScanner(ctx, false)).To(Succeed()) + + Expect(refreshStatsCalls).To(HaveLen(1)) + Expect(refreshStatsCalls[0]).To(BeFalse(), "RefreshStats should be called with allArtists=false for incremental scans") + }) + + It("should update artist stats during quick scans when new albums are added", func() { + // Don't use the mocked artist repo for this test - we need the real one + ds.MockedArtist = nil + + By("Initial scan with one album") + Expect(runScanner(ctx, true)).To(Succeed()) + + // Verify initial artist stats - should have 1 album, 1 song + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(1)) + artist := artists[0] + Expect(artist.AlbumCount).To(Equal(1)) // 1 album + Expect(artist.SongCount).To(Equal(1)) // 1 song + + By("Adding files to an existing directory during incremental scan") + // Add more files to the existing Help! album - this should trigger artist stats update during incremental scan + fsys.Add("The Beatles/Help!/02 - The Night Before.mp3", help(track(2, "The Night Before"))) + fsys.Add("The Beatles/Help!/03 - You've Got to Hide Your Love Away.mp3", help(track(3, "You've Got to Hide Your Love Away"))) + + // Do a quick scan (incremental) + Expect(runScanner(ctx, false)).To(Succeed()) + + By("Verifying artist stats were updated correctly") + // Fetch the artist again to check updated stats + artists, err = ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(1)) + updatedArtist := artists[0] + + // Should now have 1 album and 3 songs total + // This is the key test - that artist stats are updated during quick scans + Expect(updatedArtist.AlbumCount).To(Equal(1)) // 1 album + Expect(updatedArtist.SongCount).To(Equal(3)) // 3 songs + + // Also verify that role-specific stats are updated (albumartist role) + Expect(updatedArtist.Stats).To(HaveKey(model.RoleAlbumArtist)) + albumArtistStats := updatedArtist.Stats[model.RoleAlbumArtist] + Expect(albumArtistStats.AlbumCount).To(Equal(1)) // 1 album + Expect(albumArtistStats.SongCount).To(Equal(3)) // 3 songs + }) }) }) @@ -528,3 +731,13 @@ func (m *mockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCur } return m.MediaFileRepository.GetMissingAndMatching(libId) } + +type testArtistRepo struct { + model.ArtistRepository + callTracker *[]bool +} + +func (m *testArtistRepo) RefreshStats(allArtists bool) (int64, error) { + *m.callTracker = append(*m.callTracker, allArtists) + return m.ArtistRepository.RefreshStats(allArtists) +} diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 4f9f26b1b..63854d262 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -9,78 +9,15 @@ import ( "slices" "sort" "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/chrono" ignore "github.com/sabhiram/go-gitignore" ) -type folderEntry struct { - job *scanJob - elapsed chrono.Meter - path string // Full path - id string // DB ID - modTime time.Time // From FS - updTime time.Time // from DB - audioFiles map[string]fs.DirEntry - imageFiles map[string]fs.DirEntry - numPlaylists int - numSubFolders int - imagesUpdatedAt time.Time - tracks model.MediaFiles - albums model.Albums - albumIDMap map[string]string - artists model.Artists - tags model.TagList - missingTracks []*model.MediaFile -} - -func (f *folderEntry) hasNoFiles() bool { - return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 && f.numSubFolders == 0 -} - -func (f *folderEntry) isNew() bool { - return f.updTime.IsZero() -} - -func (f *folderEntry) toFolder() *model.Folder { - folder := model.NewFolder(f.job.lib, f.path) - folder.NumAudioFiles = len(f.audioFiles) - if core.InPlaylistsPath(*folder) { - folder.NumPlaylists = f.numPlaylists - } - folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) - folder.ImagesUpdatedAt = f.imagesUpdatedAt - return folder -} - -func newFolderEntry(job *scanJob, path string) *folderEntry { - id := model.FolderID(job.lib, path) - f := &folderEntry{ - id: id, - job: job, - path: path, - audioFiles: make(map[string]fs.DirEntry), - imageFiles: make(map[string]fs.DirEntry), - albumIDMap: make(map[string]string), - updTime: job.popLastUpdate(id), - } - return f -} - -func (f *folderEntry) isOutdated() bool { - if f.job.lib.FullScanInProgress { - return f.updTime.Before(f.job.lib.LastScanStartedAt) - } - return f.updTime.Before(f.modTime) -} - func walkDirTree(ctx context.Context, job *scanJob) (<-chan *folderEntry, error) { results := make(chan *folderEntry) go func() { diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index 062bf4344..b377e7947 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -9,7 +9,8 @@ import ( type Scheduler interface { Run(ctx context.Context) - Add(crontab string, cmd func()) error + Add(crontab string, cmd func()) (int, error) + Remove(id int) } func GetInstance() Scheduler { @@ -31,7 +32,14 @@ func (s *scheduler) Run(ctx context.Context) { s.c.Stop() } -func (s *scheduler) Add(crontab string, cmd func()) error { - _, err := s.c.AddFunc(crontab, cmd) - return err +func (s *scheduler) Add(crontab string, cmd func()) (int, error) { + entryID, err := s.c.AddFunc(crontab, cmd) + if err != nil { + return 0, err + } + return int(entryID), nil +} + +func (s *scheduler) Remove(id int) { + s.c.Remove(cron.EntryID(id)) } diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go new file mode 100644 index 000000000..4737ae389 --- /dev/null +++ b/scheduler/scheduler_test.go @@ -0,0 +1,86 @@ +package scheduler + +import ( + "sync" + "testing" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/robfig/cron/v3" +) + +func TestScheduler(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Scheduler Suite") +} + +var _ = Describe("Scheduler", func() { + var s *scheduler + + BeforeEach(func() { + c := cron.New(cron.WithLogger(&logger{})) + s = &scheduler{c: c} + s.c.Start() // Start the scheduler for tests + }) + + AfterEach(func() { + s.c.Stop() // Stop the scheduler after tests + }) + + It("adds and executes a job", func() { + wg := sync.WaitGroup{} + wg.Add(1) + + executed := false + id, err := s.Add("@every 100ms", func() { + executed = true + wg.Done() + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeZero()) + + wg.Wait() + Expect(executed).To(BeTrue()) + }) + + It("removes a job", func() { + // Use a WaitGroup to ensure the job executes once + wg := sync.WaitGroup{} + wg.Add(1) + + counter := 0 + id, err := s.Add("@every 100ms", func() { + counter++ + if counter == 1 { + wg.Done() // Signal that the job has executed once + } + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeZero()) + + // Wait for the job to execute at least once + wg.Wait() + + // Verify job executed + Expect(counter).To(Equal(1)) + + // Remove the job + s.Remove(id) + + // Store the counter value + currentCount := counter + + // Wait some time to ensure job doesn't execute again + time.Sleep(200 * time.Millisecond) + + // Verify counter didn't increase + Expect(counter).To(Equal(currentCount)) + }) +}) diff --git a/server/auth.go b/server/auth.go index 5b35f72ed..ed43974dd 100644 --- a/server/auth.go +++ b/server/auth.go @@ -171,7 +171,7 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m return u, nil } -func jwtVerifier(next http.Handler) http.Handler { +func JWTVerifier(next http.Handler) http.Handler { return jwtauth.Verify(auth.TokenAuth, tokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next) } @@ -214,6 +214,15 @@ func UsernameFromReverseProxyHeader(r *http.Request) string { return username } +func InternalAuth(r *http.Request) string { + username, ok := request.InternalAuthFrom(r.Context()) + if !ok { + return "" + } + log.Trace(r, "Found username in InternalAuth", "username", username) + return username +} + func UsernameFromConfig(*http.Request) string { return conf.Server.DevAutoLoginUsername } diff --git a/server/events/events.go b/server/events/events.go index 73ff8eb5e..e8dcd81f0 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -63,6 +63,11 @@ type RefreshResource struct { resources map[string][]string } +type NowPlayingCount struct { + baseEvent + Count int `json:"count"` +} + func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource { if rr.resources == nil { rr.resources = make(map[string][]string) diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go new file mode 100644 index 000000000..d708d72f9 --- /dev/null +++ b/server/nativeapi/config.go @@ -0,0 +1,138 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/request" +) + +// sensitiveFieldsPartialMask contains configuration field names that should be redacted +// using partial masking (first and last character visible, middle replaced with *). +// For values with 7+ characters: "secretvalue123" becomes "s***********3" +// For values with <7 characters: "short" becomes "****" +// Add field paths using dot notation (e.g., "LastFM.ApiKey", "Spotify.Secret") +var sensitiveFieldsPartialMask = []string{ + "LastFM.ApiKey", + "LastFM.Secret", + "Prometheus.MetricsPath", + "Spotify.ID", + "Spotify.Secret", + "DevAutoLoginUsername", +} + +// sensitiveFieldsFullMask contains configuration field names that should always be +// completely masked with "****" regardless of their length. +// Add field paths using dot notation for any fields that should never show any content. +var sensitiveFieldsFullMask = []string{ + "DevAutoCreateAdminPassword", + "PasswordEncryptionKey", + "Prometheus.Password", +} + +type configResponse struct { + ID string `json:"id"` + ConfigFile string `json:"configFile"` + Config map[string]interface{} `json:"config"` +} + +func redactValue(key string, value string) string { + // Return empty values as-is + if len(value) == 0 { + return value + } + + // Check if this field should be fully masked + for _, field := range sensitiveFieldsFullMask { + if field == key { + return "****" + } + } + + // Check if this field should be partially masked + for _, field := range sensitiveFieldsPartialMask { + if field == key { + if len(value) < 7 { + return "****" + } + // Show first and last character with * in between + return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1]) + } + } + + // Return original value if not sensitive + return value +} + +// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map +func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) { + for key, value := range config { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case map[string]interface{}: + // Recursively process nested maps + applySensitiveFieldMasking(ctx, v, fullKey) + case string: + // Apply masking to string values + config[key] = redactValue(fullKey, v) + default: + // For other types (numbers, booleans, etc.), convert to string and check for masking + if str := fmt.Sprint(v); str != "" { + masked := redactValue(fullKey, str) + if masked != str { + // Only replace if masking was applied + config[key] = masked + } + } + } + } +} + +func getConfig(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(ctx) + if !user.IsAdmin { + http.Error(w, "Config endpoint is only available to admin users", http.StatusUnauthorized) + return + } + + // Marshal the actual configuration struct to preserve original field names + configBytes, err := json.Marshal(*conf.Server) + if err != nil { + log.Error(ctx, "Error marshaling config", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Unmarshal back to map to get the structure with proper field names + var configMap map[string]interface{} + err = json.Unmarshal(configBytes, &configMap) + if err != nil { + log.Error(ctx, "Error unmarshaling config to map", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Apply sensitive field masking + applySensitiveFieldMasking(ctx, configMap, "") + + resp := configResponse{ + ID: "config", + ConfigFile: conf.Server.ConfigFile, + Config: configMap, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Error(ctx, "Error encoding config response", err) + } +} diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go new file mode 100644 index 000000000..52baef83a --- /dev/null +++ b/server/nativeapi/config_test.go @@ -0,0 +1,147 @@ +package nativeapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("getConfig", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Context("when user is not admin", func() { + It("returns unauthorized", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: false}) + + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Context("when user is admin", func() { + It("returns config successfully", func() { + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.ID).To(Equal("config")) + Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile)) + Expect(resp.Config).ToNot(BeEmpty()) + }) + + It("redacts sensitive fields", func() { + conf.Server.LastFM.ApiKey = "secretapikey123" + conf.Server.Spotify.Secret = "spotifysecret456" + conf.Server.PasswordEncryptionKey = "encryptionkey789" + conf.Server.DevAutoCreateAdminPassword = "adminpassword123" + conf.Server.Prometheus.Password = "prometheuspass" + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + // Check LastFM.ApiKey (partially masked) + lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(lastfm["ApiKey"]).To(Equal("s*************3")) + + // Check Spotify.Secret (partially masked) + spotify, ok := resp.Config["Spotify"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(spotify["Secret"]).To(Equal("s**************6")) + + // Check PasswordEncryptionKey (fully masked) + Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****")) + + // Check DevAutoCreateAdminPassword (fully masked) + Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****")) + + // Check Prometheus.Password (fully masked) + prometheus, ok := resp.Config["Prometheus"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(prometheus["Password"]).To(Equal("****")) + }) + + It("handles empty sensitive values", func() { + conf.Server.LastFM.ApiKey = "" + conf.Server.PasswordEncryptionKey = "" + + req := httptest.NewRequest("GET", "/config", nil) + w := httptest.NewRecorder() + ctx := request.WithUser(req.Context(), model.User{IsAdmin: true}) + getConfig(w, req.WithContext(ctx)) + + Expect(w.Code).To(Equal(http.StatusOK)) + var resp configResponse + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + + // Check LastFM.ApiKey - should be preserved because it's sensitive + lastfm, ok := resp.Config["LastFM"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(lastfm["ApiKey"]).To(Equal("")) + + // Empty sensitive values should remain empty - should be preserved because it's sensitive + Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("")) + }) + }) +}) + +var _ = Describe("redactValue function", func() { + It("partially masks long sensitive values", func() { + Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a")) + Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3")) + }) + + It("fully masks long sensitive values that should be completely hidden", func() { + Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****")) + Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****")) + Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****")) + }) + + It("fully masks short sensitive values", func() { + Expect(redactValue("LastFM.Secret", "short")).To(Equal("****")) + Expect(redactValue("Spotify.ID", "abc")).To(Equal("****")) + Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****")) + Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****")) + Expect(redactValue("Prometheus.Password", "short")).To(Equal("****")) + }) + + It("does not mask non-sensitive values", func() { + Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music")) + Expect(redactValue("Port", "4533")).To(Equal("4533")) + Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue")) + }) + + It("handles empty values", func() { + Expect(redactValue("LastFM.ApiKey", "")).To(Equal("")) + Expect(redactValue("NonSensitive", "")).To(Equal("")) + }) + + It("handles edge case values", func() { + Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****")) + Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****")) + Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g")) + }) +}) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 74e645248..0d311f492 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -10,6 +10,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/req" ) @@ -63,28 +64,43 @@ func (r *missingRepository) EntityName() string { } func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) { - repo := ds.MediaFile(r.Context()) + ctx := r.Context() p := req.Params(r) ids, _ := p.Strings("id") err := ds.WithTx(func(tx model.DataStore) error { - return repo.DeleteMissing(ids) + if len(ids) == 0 { + _, err := tx.MediaFile(ctx).DeleteAllMissing() + return err + } + return tx.MediaFile(ctx).DeleteMissing(ids) }) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { - log.Warn(r.Context(), "Missing file not found", "id", ids[0]) + log.Warn(ctx, "Missing file not found", "id", ids[0]) http.Error(w, "not found", http.StatusNotFound) return } if err != nil { - log.Error(r.Context(), "Error deleting missing tracks from DB", "ids", ids, err) + log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - err = ds.GC(r.Context()) + err = ds.GC(ctx) if err != nil { - log.Error(r.Context(), "Error running GC after deleting missing tracks", err) + log.Error(ctx, "Error running GC after deleting missing tracks", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } + + // Refresh artist stats in background after deleting missing files + go func() { + bgCtx := request.AddValues(context.Background(), r.Context()) + if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil { + log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) + } else { + log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") + } + }() + writeDeleteManyResponse(w, r, ids) } diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index ddf5df1c3..aed24e963 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -59,23 +59,13 @@ func (n *Router) routes() http.Handler { n.addPlaylistRoute(r) n.addPlaylistTrackRoute(r) + n.addSongPlaylistsRoute(r) + n.addQueueRoute(r) n.addMissingFilesRoute(r) n.addInspectRoute(r) - - // Keepalive endpoint to be used to keep the session valid (ex: while playing songs) - r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) - }) - - // Insights status endpoint - r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { - last, success := n.insights.LastRun(r.Context()) - if conf.Server.EnableInsightsCollector { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) - } else { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`)) - } - }) + n.addConfigRoute(r) + n.addKeepAliveRoute(r) + n.addInsightsRoute(r) }) return r @@ -144,6 +134,9 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) { }) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + getPlaylistTrack(n.ds)(w, r) + }) r.Put("/", func(w http.ResponseWriter, r *http.Request) { reorderItem(n.ds)(w, r) }) @@ -154,6 +147,21 @@ func (n *Router) addPlaylistTrackRoute(r chi.Router) { }) } +func (n *Router) addSongPlaylistsRoute(r chi.Router) { + r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) { + getSongPlaylists(n.ds)(w, r) + }) +} + +func (n *Router) addQueueRoute(r chi.Router) { + r.Route("/queue", func(r chi.Router) { + r.Get("/", getQueue(n.ds)) + r.Post("/", saveQueue(n.ds)) + r.Put("/", updateQueue(n.ds)) + r.Delete("/", clearQueue(n.ds)) + }) +} + func (n *Router) addMissingFilesRoute(r chi.Router) { r.Route("/missing", func(r chi.Router) { n.RX(r, "/", newMissingRepository(n.ds), false) @@ -196,3 +204,26 @@ func (n *Router) addInspectRoute(r chi.Router) { }) } } + +func (n *Router) addConfigRoute(r chi.Router) { + if conf.Server.DevUIShowConfig { + r.Get("/config/*", getConfig) + } +} + +func (n *Router) addKeepAliveRoute(r chi.Router) { + r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) + }) +} + +func (n *Router) addInsightsRoute(r chi.Router) { + r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { + last, success := n.insights.LastRun(r.Context()) + if conf.Server.EnableInsightsCollector { + _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) + } else { + _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`)) + } + }) +} diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go new file mode 100644 index 000000000..0b183c1d9 --- /dev/null +++ b/server/nativeapi/native_api_song_test.go @@ -0,0 +1,464 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "time" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Simple mock implementations for missing types +type mockShare struct { + core.Share +} + +func (m *mockShare) NewRepository(ctx context.Context) rest.Repository { + return &tests.MockShareRepo{} +} + +type mockPlaylists struct { + core.Playlists +} + +func (m *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + return &model.Playlist{}, nil +} + +type mockInsights struct { + metrics.Insights +} + +func (m *mockInsights) LastRun(ctx context.Context) (time.Time, bool) { + return time.Now(), true +} + +var _ = Describe("Song Endpoints", func() { + var ( + router http.Handler + ds *tests.MockDataStore + mfRepo *tests.MockMediaFileRepo + userRepo *tests.MockedUserRepo + w *httptest.ResponseRecorder + testUser model.User + testSongs model.MediaFiles + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + + // Setup mock repositories + mfRepo = tests.CreateMockMediaFileRepo() + userRepo = tests.CreateMockUserRepo() + + ds = &tests.MockDataStore{ + MockedMediaFile: mfRepo, + MockedUser: userRepo, + MockedProperty: &tests.MockedPropertyRepo{}, + } + + // Initialize auth system + auth.Init(ds) + + // Create test user + testUser = model.User{ + ID: "user-1", + UserName: "testuser", + Name: "Test User", + IsAdmin: false, + NewPassword: "testpass", + } + err := userRepo.Put(&testUser) + Expect(err).ToNot(HaveOccurred()) + + // Create test songs + testSongs = model.MediaFiles{ + { + ID: "song-1", + Title: "Test Song 1", + Artist: "Test Artist 1", + Album: "Test Album 1", + AlbumID: "album-1", + ArtistID: "artist-1", + Duration: 180.5, + BitRate: 320, + Path: "/music/song1.mp3", + Suffix: "mp3", + Size: 5242880, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + { + ID: "song-2", + Title: "Test Song 2", + Artist: "Test Artist 2", + Album: "Test Album 2", + AlbumID: "album-2", + ArtistID: "artist-2", + Duration: 240.0, + BitRate: 256, + Path: "/music/song2.mp3", + Suffix: "mp3", + Size: 7340032, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + mfRepo.SetData(testSongs) + + // Setup router with mocked dependencies + mockShareImpl := &mockShare{} + mockPlaylistsImpl := &mockPlaylists{} + mockInsightsImpl := &mockInsights{} + + // Create the native API router and wrap it with the JWTVerifier middleware + nativeRouter := New(ds, mockShareImpl, mockPlaylistsImpl, mockInsightsImpl) + router = server.JWTVerifier(nativeRouter) + w = httptest.NewRecorder() + }) + + // Helper function to create unauthenticated request + createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + return req + } + + // Helper function to create authenticated request with JWT token + createAuthenticatedRequest := func(method, path string, body []byte) *http.Request { + req := createUnauthenticatedRequest(method, path, body) + + // Create JWT token for the test user + token, err := auth.CreateToken(&testUser) + Expect(err).ToNot(HaveOccurred()) + + // Add JWT token to Authorization header + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + + return req + } + + Describe("GET /song", func() { + Context("when user is authenticated", func() { + It("returns all songs", func() { + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response []model.MediaFile + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + + Expect(response).To(HaveLen(2)) + Expect(response[0].ID).To(Equal("song-1")) + Expect(response[0].Title).To(Equal("Test Song 1")) + Expect(response[1].ID).To(Equal("song-2")) + Expect(response[1].Title).To(Equal("Test Song 2")) + }) + + It("handles repository errors gracefully", func() { + mfRepo.SetError(true) + + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Context("when user is not authenticated", func() { + It("returns unauthorized", func() { + req := createUnauthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + }) + + Describe("GET /song/{id}", func() { + Context("when user is authenticated", func() { + It("returns the specific song", func() { + req := createAuthenticatedRequest("GET", "/song/song-1", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response model.MediaFile + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + + Expect(response.ID).To(Equal("song-1")) + Expect(response.Title).To(Equal("Test Song 1")) + Expect(response.Artist).To(Equal("Test Artist 1")) + }) + + It("returns 404 for non-existent song", func() { + req := createAuthenticatedRequest("GET", "/song/non-existent", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("handles repository errors gracefully", func() { + mfRepo.SetError(true) + + req := createAuthenticatedRequest("GET", "/song/song-1", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Context("when user is not authenticated", func() { + It("returns unauthorized", func() { + req := createUnauthenticatedRequest("GET", "/song/song-1", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + }) + + Describe("Song endpoints are read-only", func() { + Context("POST /song", func() { + It("should not be available (songs are not persistable)", func() { + newSong := model.MediaFile{ + Title: "New Song", + Artist: "New Artist", + Album: "New Album", + Duration: 200.0, + } + + body, _ := json.Marshal(newSong) + req := createAuthenticatedRequest("POST", "/song", body) + router.ServeHTTP(w, req) + + // Should return 405 Method Not Allowed or 404 Not Found + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + }) + }) + + Context("PUT /song/{id}", func() { + It("should not be available (songs are not persistable)", func() { + updatedSong := model.MediaFile{ + ID: "song-1", + Title: "Updated Song", + Artist: "Updated Artist", + Album: "Updated Album", + Duration: 250.0, + } + + body, _ := json.Marshal(updatedSong) + req := createAuthenticatedRequest("PUT", "/song/song-1", body) + router.ServeHTTP(w, req) + + // Should return 405 Method Not Allowed or 404 Not Found + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + }) + }) + + Context("DELETE /song/{id}", func() { + It("should not be available (songs are not persistable)", func() { + req := createAuthenticatedRequest("DELETE", "/song/song-1", nil) + router.ServeHTTP(w, req) + + // Should return 405 Method Not Allowed or 404 Not Found + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + }) + }) + }) + + Describe("Query parameters and filtering", func() { + Context("when using query parameters", func() { + It("handles pagination parameters", func() { + req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response []model.MediaFile + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + + // Should still return all songs since our mock doesn't implement pagination + // but the request should be processed successfully + Expect(len(response)).To(BeNumerically(">=", 1)) + }) + + It("handles sort parameters", func() { + req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response []model.MediaFile + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + + Expect(response).To(HaveLen(2)) + }) + + It("handles filter parameters", func() { + // Properly encode the URL with query parameters + baseURL := "/song" + params := url.Values{} + params.Add("title", "Test Song 1") + fullURL := baseURL + "?" + params.Encode() + + req := createAuthenticatedRequest("GET", fullURL, nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response []model.MediaFile + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + + // Mock doesn't implement filtering, but request should be processed + Expect(len(response)).To(BeNumerically(">=", 1)) + }) + }) + }) + + Describe("Response headers and content type", func() { + It("sets correct content type for JSON responses", func() { + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + }) + + It("includes total count header when available", func() { + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + // The X-Total-Count header might be set by the REST framework + // We just verify the request is processed successfully + }) + }) + + Describe("Edge cases and error handling", func() { + Context("when repository is unavailable", func() { + It("handles database connection errors", func() { + mfRepo.SetError(true) + + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Context("when no songs exist", func() { + It("returns empty array when no songs are found", func() { + mfRepo.SetData(model.MediaFiles{}) // Empty dataset + + req := createAuthenticatedRequest("GET", "/song", nil) + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + + var response []model.MediaFile + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).ToNot(HaveOccurred()) + + Expect(response).To(HaveLen(0)) + }) + }) + }) + + Describe("Authentication middleware integration", func() { + Context("with different user types", func() { + It("works with admin users", func() { + adminUser := model.User{ + ID: "admin-1", + UserName: "admin", + Name: "Admin User", + IsAdmin: true, + NewPassword: "adminpass", + } + err := userRepo.Put(&adminUser) + Expect(err).ToNot(HaveOccurred()) + + // Create JWT token for admin user + token, err := auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + + req := createUnauthenticatedRequest("GET", "/song", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("works with regular users", func() { + regularUser := model.User{ + ID: "user-2", + UserName: "regular", + Name: "Regular User", + IsAdmin: false, + NewPassword: "userpass", + } + err := userRepo.Put(®ularUser) + Expect(err).ToNot(HaveOccurred()) + + // Create JWT token for regular user + token, err := auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + + req := createUnauthenticatedRequest("GET", "/song", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("with missing authentication context", func() { + It("rejects requests without user context", func() { + req := createUnauthenticatedRequest("GET", "/song", nil) + // No authentication header added + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects requests with invalid JWT tokens", func() { + req := createUnauthenticatedRequest("GET", "/song", nil) + req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here") + + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + }) +}) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 1e8e961ca..17af19475 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -45,6 +45,23 @@ func getPlaylist(ds model.DataStore) http.HandlerFunc { } } +func getPlaylistTrack(ds model.DataStore) http.HandlerFunc { + // Add a middleware to capture the playlistId + wrapper := func(handler restHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + constructor := func(ctx context.Context) rest.Repository { + plsRepo := ds.Playlist(ctx) + plsId := chi.URLParam(r, "playlistId") + return plsRepo.Tracks(plsId, true) + } + + handler(constructor).ServeHTTP(w, r) + } + } + + return wrapper(rest.Get) +} + func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -207,3 +224,21 @@ func reorderItem(ds model.DataStore) http.HandlerFunc { } } } + +func getSongPlaylists(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + trackId, _ := p.String(":id") + playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + data, err := json.Marshal(playlists) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = w.Write(data) + } +} diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go new file mode 100644 index 000000000..0a3136660 --- /dev/null +++ b/server/nativeapi/queue.go @@ -0,0 +1,214 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/slice" +) + +type updateQueuePayload struct { + Ids *[]string `json:"ids,omitempty"` + Current *int `json:"current,omitempty"` + Position *int64 `json:"position,omitempty"` +} + +// validateCurrentIndex validates that the current index is within bounds of the items array. +// Returns false if validation fails (and sends error response), true if validation passes. +func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) bool { + if current < 0 || current >= itemsLength { + http.Error(w, "current index out of bounds", http.StatusBadRequest) + return false + } + return true +} + +// retrieveExistingQueue retrieves an existing play queue for a user with proper error handling. +// Returns the queue (nil if not found) and false if an error occurred and response was sent. +func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) { + existing, err := ds.PlayQueue(ctx).Retrieve(userID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Error retrieving queue", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil, false + } + return existing, true +} + +// decodeUpdatePayload decodes the JSON payload from the request body. +// Returns false if decoding fails (and sends error response), true if successful. +func decodeUpdatePayload(w http.ResponseWriter, r *http.Request) (*updateQueuePayload, bool) { + var payload updateQueuePayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return nil, false + } + return &payload, true +} + +// createMediaFileItems converts a slice of IDs to MediaFile items. +func createMediaFileItems(ids []string) []model.MediaFile { + return slice.Map(ids, func(id string) model.MediaFile { + return model.MediaFile{ID: id} + }) +} + +// extractUserAndClient extracts user and client from the request context. +func extractUserAndClient(ctx context.Context) (model.User, string) { + user, _ := request.UserFrom(ctx) + client, _ := request.ClientFrom(ctx) + return user, client +} + +func getQueue(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(ctx) + repo := ds.PlayQueue(ctx) + pq, err := repo.RetrieveWithMediaFiles(user.ID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Error retrieving queue", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if pq == nil { + pq = &model.PlayQueue{} + } + resp, err := json.Marshal(pq) + if err != nil { + log.Error(ctx, "Error marshalling queue", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(resp) + } +} + +func saveQueue(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + payload, ok := decodeUpdatePayload(w, r) + if !ok { + return + } + user, client := extractUserAndClient(ctx) + ids := V(payload.Ids) + items := createMediaFileItems(ids) + current := V(payload.Current) + if len(ids) > 0 && !validateCurrentIndex(w, current, len(ids)) { + return + } + pq := &model.PlayQueue{ + UserID: user.ID, + Current: current, + Position: max(V(payload.Position), 0), + ChangedBy: client, + Items: items, + } + if err := ds.PlayQueue(ctx).Store(pq); err != nil { + log.Error(ctx, "Error saving queue", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func updateQueue(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Decode and validate the JSON payload + payload, ok := decodeUpdatePayload(w, r) + if !ok { + return + } + + // Extract user and client information from request context + user, client := extractUserAndClient(ctx) + + // Initialize play queue with user ID and client info + pq := &model.PlayQueue{UserID: user.ID, ChangedBy: client} + var cols []string // Track which columns to update in the database + + // Handle queue items update + if payload.Ids != nil { + pq.Items = createMediaFileItems(*payload.Ids) + cols = append(cols, "items") + + // If current index is not being updated, validate existing current index + // against the new items list to ensure it remains valid + if payload.Current == nil { + existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID) + if !ok { + return + } + if existing != nil && !validateCurrentIndex(w, existing.Current, len(*payload.Ids)) { + return + } + } + } + + // Handle current track index update + if payload.Current != nil { + pq.Current = *payload.Current + cols = append(cols, "current") + + if payload.Ids != nil { + // If items are also being updated, validate current index against new items + if !validateCurrentIndex(w, *payload.Current, len(*payload.Ids)) { + return + } + } else { + // If only current index is being updated, validate against existing items + existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID) + if !ok { + return + } + if existing != nil && !validateCurrentIndex(w, *payload.Current, len(existing.Items)) { + return + } + } + } + + // Handle playback position update + if payload.Position != nil { + pq.Position = max(*payload.Position, 0) // Ensure position is non-negative + cols = append(cols, "position") + } + + // If no fields were specified for update, return success without doing anything + if len(cols) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + + // Perform partial update of the specified columns only + if err := ds.PlayQueue(ctx).Store(pq, cols...); err != nil { + log.Error(ctx, "Error updating queue", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func clearQueue(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(ctx) + if err := ds.PlayQueue(ctx).Clear(user.ID); err != nil { + log.Error(ctx, "Error clearing queue", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go new file mode 100644 index 000000000..ef971ee68 --- /dev/null +++ b/server/nativeapi/queue_test.go @@ -0,0 +1,282 @@ +package nativeapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Queue Endpoints", func() { + var ( + ds *tests.MockDataStore + repo *tests.MockPlayQueueRepo + user model.User + userRepo *tests.MockedUserRepo + ) + + BeforeEach(func() { + repo = &tests.MockPlayQueueRepo{} + user = model.User{ID: "u1", UserName: "user"} + userRepo = tests.CreateMockUserRepo() + _ = userRepo.Put(&user) + ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} + }) + + Describe("POST /queue", func() { + It("saves the queue", func() { + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) + ctx := request.WithUser(req.Context(), user) + ctx = request.WithClient(ctx, "TestClient") + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + saveQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(repo.Queue).ToNot(BeNil()) + Expect(repo.Queue.Current).To(Equal(1)) + Expect(repo.Queue.Items).To(HaveLen(2)) + Expect(repo.Queue.Items[1].ID).To(Equal("s2")) + Expect(repo.Queue.ChangedBy).To(Equal("TestClient")) + }) + + It("saves an empty queue", func() { + payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + saveQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(repo.Queue).ToNot(BeNil()) + Expect(repo.Queue.Items).To(HaveLen(0)) + }) + + It("returns bad request for invalid current index (negative)", func() { + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + saveQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("current index out of bounds")) + }) + + It("returns bad request for invalid current index (too large)", func() { + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + saveQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(w.Body.String()).To(ContainSubstring("current index out of bounds")) + }) + + It("returns bad request for malformed JSON", func() { + req := httptest.NewRequest("POST", "/queue", bytes.NewReader([]byte("invalid json"))) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + saveQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns internal server error when store fails", func() { + repo.Err = true + payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + saveQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("GET /queue", func() { + It("returns the queue", func() { + queue := &model.PlayQueue{ + UserID: user.ID, + Current: 1, + Position: 55, + Items: model.MediaFiles{ + {ID: "track1", Title: "Song 1"}, + {ID: "track2", Title: "Song 2"}, + {ID: "track3", Title: "Song 3"}, + }, + } + repo.Queue = queue + req := httptest.NewRequest("GET", "/queue", nil) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + getQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + var resp model.PlayQueue + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.Current).To(Equal(1)) + Expect(resp.Position).To(Equal(int64(55))) + Expect(resp.Items).To(HaveLen(3)) + Expect(resp.Items[0].ID).To(Equal("track1")) + Expect(resp.Items[1].ID).To(Equal("track2")) + Expect(resp.Items[2].ID).To(Equal("track3")) + }) + + It("returns empty queue when user has no queue", func() { + req := httptest.NewRequest("GET", "/queue", nil) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + getQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + var resp model.PlayQueue + Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.Items).To(BeEmpty()) + Expect(resp.Current).To(Equal(0)) + Expect(resp.Position).To(Equal(int64(0))) + }) + + It("returns internal server error when retrieve fails", func() { + repo.Err = true + req := httptest.NewRequest("GET", "/queue", nil) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + getQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("PUT /queue", func() { + It("updates the queue fields", func() { + repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}} + payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) + ctx := request.WithUser(req.Context(), user) + ctx = request.WithClient(ctx, "TestClient") + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(repo.Queue).ToNot(BeNil()) + Expect(repo.Queue.Current).To(Equal(2)) + Expect(repo.Queue.Position).To(Equal(int64(20))) + Expect(repo.Queue.ChangedBy).To(Equal("TestClient")) + }) + + It("updates only ids", func() { + repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(repo.Queue.Items).To(HaveLen(2)) + Expect(repo.LastCols).To(ConsistOf("items")) + }) + + It("updates ids and current", func() { + repo.Queue = &model.PlayQueue{UserID: user.ID} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(repo.Queue.Items).To(HaveLen(2)) + Expect(repo.Queue.Current).To(Equal(1)) + Expect(repo.LastCols).To(ConsistOf("items", "current")) + }) + + It("returns bad request when new ids invalidate current", func() { + repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2} + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns bad request when current out of bounds", func() { + repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} + payload := updateQueuePayload{Current: gg.P(3)} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns bad request for malformed JSON", func() { + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader([]byte("{"))) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns internal server error when store fails", func() { + repo.Err = true + payload := updateQueuePayload{Position: gg.P(int64(10))} + body, _ := json.Marshal(payload) + req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + updateQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) + + Describe("DELETE /queue", func() { + It("clears the queue", func() { + repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} + req := httptest.NewRequest("DELETE", "/queue", nil) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + clearQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusNoContent)) + Expect(repo.Queue).To(BeNil()) + }) + + It("returns internal server error when clear fails", func() { + repo.Err = true + req := httptest.NewRequest("DELETE", "/queue", nil) + req = req.WithContext(request.WithUser(req.Context(), user)) + w := httptest.NewRecorder() + + clearQueue(ds)(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/public/handle_images.go b/server/public/handle_images.go index f178692f8..55a851c6f 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -37,8 +37,9 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } size := p.IntOr("size", 0) + square := p.BoolOr("square", false) - imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, false) + imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) switch { case errors.Is(err, context.Canceled): return diff --git a/server/serve_index.go b/server/serve_index.go index 9a457ac20..38e646982 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -55,6 +55,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, "enableCoverAnimation": conf.Server.EnableCoverAnimation, + "enableNowPlaying": conf.Server.EnableNowPlaying, "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, @@ -65,6 +66,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "devSidebarPlaylists": conf.Server.DevSidebarPlaylists, "lastFMEnabled": conf.Server.LastFM.Enabled, "devShowArtistPage": conf.Server.DevShowArtistPage, + "devUIShowConfig": conf.Server.DevUIShowConfig, + "devNewEventStream": conf.Server.DevNewEventStream, "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 0f02153fd..4f179f22a 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -39,7 +39,7 @@ var _ = Describe("serveIndex", func() { Expect(w.Code).To(Equal(200)) config := extractAppConfig(w.Body.String()) - Expect(config).To(BeAssignableToTypeOf(map[string]interface{}{})) + Expect(config).To(BeAssignableToTypeOf(map[string]any{})) }) It("sets firstTime = true when User table is empty", func() { @@ -53,17 +53,6 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("firstTime", true)) }) - It("includes the VariousArtistsID", func() { - mockUser.empty = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("variousArtistsId", consts.VariousArtistsID)) - }) - It("sets firstTime = false when User table is not empty", func() { mockUser.empty = false r := httptest.NewRequest("GET", "/index.html", nil) @@ -75,267 +64,64 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("firstTime", false)) }) - It("sets baseURL", func() { - conf.Server.BasePath = "base_url_test" - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("baseURL", "base_url_test")) - }) - - It("sets the welcomeMessage", func() { - conf.Server.UIWelcomeMessage = "Hello" - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("welcomeMessage", "Hello")) - }) - - It("sets the maxSidebarPlaylists", func() { - conf.Server.MaxSidebarPlaylists = 42 - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("maxSidebarPlaylists", float64(42))) - }) - - It("sets the enableTranscodingConfig", func() { - conf.Server.EnableTranscodingConfig = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableTranscodingConfig", true)) - }) - - It("sets the enableDownloads", func() { - conf.Server.EnableDownloads = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableDownloads", true)) - }) - - It("sets the enableLoved", func() { - conf.Server.EnableFavourites = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableFavourites", true)) - }) - - It("sets the enableStarRating", func() { - conf.Server.EnableStarRating = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableStarRating", true)) - }) - - It("sets the defaultTheme", func() { - conf.Server.DefaultTheme = "Light" - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("defaultTheme", "Light")) - }) - - It("sets the defaultLanguage", func() { - conf.Server.DefaultLanguage = "pt" - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("defaultLanguage", "pt")) - }) - - It("sets the defaultUIVolume", func() { - conf.Server.DefaultUIVolume = 45 - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("defaultUIVolume", float64(45))) - }) - - It("sets the enableCoverAnimation", func() { - conf.Server.EnableCoverAnimation = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableCoverAnimation", true)) - }) - - It("sets the gaTrackingId", func() { - conf.Server.GATrackingID = "UA-12345" - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("gaTrackingId", "UA-12345")) - }) - - It("sets the version", func() { - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("version", consts.Version)) - }) - - It("sets the losslessFormats", func() { - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - expected := strings.ToUpper(strings.Join(mime.LosslessFormats, ",")) - Expect(config).To(HaveKeyWithValue("losslessFormats", expected)) - }) - - It("sets the enableUserEditing", func() { - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableUserEditing", true)) - }) - - It("sets the enableSharing", func() { - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableSharing", false)) - }) - - It("sets the defaultDownloadableShare", func() { - conf.Server.DefaultDownloadableShare = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("defaultDownloadableShare", true)) - }) - - It("sets the defaultDownsamplingFormat", func() { - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("defaultDownsamplingFormat", conf.Server.DefaultDownsamplingFormat)) - }) - - It("sets the devSidebarPlaylists", func() { - conf.Server.DevSidebarPlaylists = true - - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("devSidebarPlaylists", true)) - }) - - It("sets the lastFMEnabled", func() { - conf.Server.LastFM.Enabled = true - - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("lastFMEnabled", true)) - }) - - It("sets the devShowArtistPage", func() { - conf.Server.DevShowArtistPage = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("devShowArtistPage", true)) - }) - - It("sets the listenBrainzEnabled", func() { - conf.Server.ListenBrainz.Enabled = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("listenBrainzEnabled", true)) - }) - - It("sets the enableReplayGain", func() { - conf.Server.EnableReplayGain = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableReplayGain", true)) - }) - - It("sets the enableExternalServices", func() { - conf.Server.EnableExternalServices = true - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue("enableExternalServices", true)) - }) + DescribeTable("sets configuration values", + func(configSetter func(), configKey string, expectedValue any) { + configSetter() + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue(configKey, expectedValue)) + }, + Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"), + Entry("welcomeMessage", func() { conf.Server.UIWelcomeMessage = "Hello" }, "welcomeMessage", "Hello"), + Entry("maxSidebarPlaylists", func() { conf.Server.MaxSidebarPlaylists = 42 }, "maxSidebarPlaylists", float64(42)), + Entry("enableTranscodingConfig", func() { conf.Server.EnableTranscodingConfig = true }, "enableTranscodingConfig", true), + Entry("enableDownloads", func() { conf.Server.EnableDownloads = true }, "enableDownloads", true), + Entry("enableFavourites", func() { conf.Server.EnableFavourites = true }, "enableFavourites", true), + Entry("enableStarRating", func() { conf.Server.EnableStarRating = true }, "enableStarRating", true), + Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"), + Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), + Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), + Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), + Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), + Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), + Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true), + Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true), + Entry("lastFMEnabled", func() { conf.Server.LastFM.Enabled = true }, "lastFMEnabled", true), + Entry("devShowArtistPage", func() { conf.Server.DevShowArtistPage = true }, "devShowArtistPage", true), + Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true), + Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true), + Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true), + Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true), + Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true), + Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"), + Entry("enableInspect", func() { conf.Server.Inspect.Enabled = true }, "enableInspect", true), + Entry("defaultDownsamplingFormat", func() { conf.Server.DefaultDownsamplingFormat = "mp3" }, "defaultDownsamplingFormat", "mp3"), + Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false), + Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), + Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), + ) + + DescribeTable("sets other UI configuration values", + func(configKey string, expectedValueFunc func() any) { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue(configKey, expectedValueFunc())) + }, + Entry("version", "version", func() any { return consts.Version }), + Entry("variousArtistsId", "variousArtistsId", func() any { return consts.VariousArtistsID }), + Entry("losslessFormats", "losslessFormats", func() any { + return strings.ToUpper(strings.Join(mime.LosslessFormats, ",")) + }), + Entry("separator", "separator", func() any { return string(os.PathSeparator) }), + ) Describe("loginBackgroundURL", func() { Context("empty BaseURL", func() { @@ -426,12 +212,12 @@ var _ = Describe("serveIndex", func() { var _ = Describe("addShareData", func() { var ( r *http.Request - data map[string]interface{} + data map[string]any shareInfo *model.Share ) BeforeEach(func() { - data = make(map[string]interface{}) + data = make(map[string]any) r = httptest.NewRequest("GET", "/", nil) }) @@ -516,8 +302,8 @@ var _ = Describe("addShareData", func() { var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`) -func extractAppConfig(body string) map[string]interface{} { - config := make(map[string]interface{}) +func extractAppConfig(body string) map[string]any { + config := make(map[string]any) match := appConfigRegex.FindStringSubmatch(body) if match == nil { return config diff --git a/server/server.go b/server/server.go index 60350b6b4..49391e2b6 100644 --- a/server/server.go +++ b/server/server.go @@ -173,7 +173,7 @@ func (s *Server) initRoutes() { clientUniqueIDMiddleware, compressMiddleware(), loggerInjector, - jwtVerifier, + JWTVerifier, } // Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index f187555e9..ffd1803c6 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -25,7 +25,7 @@ var _ = Describe("Album Lists", func() { BeforeEach(func() { ds = &tests.MockDataStore{} mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index fd8c3af28..bb3d20e5c 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" @@ -43,11 +44,13 @@ type Router struct { scrobbler scrobbler.PlayTracker share core.Share playback playback.PlaybackServer + metrics metrics.Metrics } func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker, playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, + metrics metrics.Metrics, ) *Router { r := &Router{ ds: ds, @@ -62,6 +65,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame scrobbler: scrobbler, share: share, playback: playback, + metrics: metrics, } r.Handler = r.routes() return r @@ -69,6 +73,11 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreame func (api *Router) routes() http.Handler { r := chi.NewRouter() + + if conf.Server.Prometheus.Enabled { + r.Use(recordStats(api.metrics)) + } + r.Use(postFormToQueryParams) // Public @@ -110,7 +119,11 @@ func (api *Router) routes() http.Handler { hr(r, "getAlbumList2", api.GetAlbumList2) h(r, "getStarred", api.GetStarred) h(r, "getStarred2", api.GetStarred2) - h(r, "getNowPlaying", api.GetNowPlaying) + if conf.Server.EnableNowPlaying { + h(r, "getNowPlaying", api.GetNowPlaying) + } else { + h501(r, "getNowPlaying") + } h(r, "getRandomSongs", api.GetRandomSongs) h(r, "getSongsByGenre", api.GetSongsByGenre) }) @@ -219,7 +232,7 @@ func h(r chi.Router, path string, f handler) { }) } -// Add a Subsonic handler that requires a http.ResponseWriter (ex: stream, getCoverArt...) +// Add a Subsonic handler that requires an http.ResponseWriter (ex: stream, getCoverArt...) func hr(r chi.Router, path string, f handlerRaw) { handle := func(w http.ResponseWriter, r *http.Request) { res, err := f(w, r) @@ -320,6 +333,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub sendError(w, r, err) return } + if payload.Status == responses.StatusOK { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response)) @@ -329,6 +343,17 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub } else { log.Warn(r.Context(), "API: Failed response", "endpoint", r.URL.Path, "error", payload.Error.Code, "message", payload.Error.Message) } + + statusPointer, ok := r.Context().Value(subsonicErrorPointer).(*int32) + + if ok && statusPointer != nil { + if payload.Status == responses.StatusOK { + *statusPointer = 0 + } else { + *statusPointer = payload.Error.Code + } + } + if _, err := w.Write(response); err != nil { log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) } diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 5d248c464..eaecd7c06 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -9,8 +9,10 @@ import ( "strings" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "golang.org/x/net/context" ) var _ = Describe("sendResponse", func() { @@ -91,7 +93,7 @@ var _ = Describe("sendResponse", func() { It("should return a fail response", func() { payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: math.Inf(1)} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() @@ -108,4 +110,18 @@ var _ = Describe("sendResponse", func() { }) }) + It("updates status pointer when an error occurs", func() { + pointer := int32(0) + + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + r = r.WithContext(ctx) + + payload.Status = responses.StatusFailed + payload.Error = &responses.Error{Code: responses.ErrorDataNotFound} + + sendResponse(w, r, payload) + Expect(w.Code).To(Equal(http.StatusOK)) + + Expect(pointer).To(Equal(responses.ErrorDataNotFound)) + }) }) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index f6fd1a99e..d7286c20c 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -73,7 +73,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) repo := api.ds.PlayQueue(r.Context()) - pq, err := repo.Retrieve(user.ID) + pq, err := repo.RetrieveWithMediaFiles(user.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } @@ -82,9 +82,13 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() + var currentID string + if pq.Current >= 0 && pq.Current < len(pq.Items) { + currentID = pq.Items[pq.Current].ID + } response.PlayQueue = &responses.PlayQueue{ Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile), - Current: pq.Current, + Current: currentID, Position: pq.Position, Username: user.UserName, Changed: &pq.UpdatedAt, @@ -96,20 +100,27 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) ids, _ := p.Strings("id") - current, _ := p.String("current") + currentID, _ := p.String("current") position := p.Int64Or("position", 0) user, _ := request.UserFrom(r.Context()) client, _ := request.ClientFrom(r.Context()) - var items model.MediaFiles - for _, id := range ids { - items = append(items, model.MediaFile{ID: id}) + items := slice.Map(ids, func(id string) model.MediaFile { + return model.MediaFile{ID: id} + }) + + currentIndex := 0 + for i, id := range ids { + if id == currentID { + currentIndex = i + break + } } pq := &model.PlayQueue{ UserID: user.ID, - Current: current, + Current: currentIndex, Position: position, ChangedBy: client, Items: items, diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 76023c862..db4e6ded1 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -343,7 +343,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) } count := p.IntOr("count", 50) - songs, err := api.provider.SimilarSongs(ctx, id, count) + songs, err := api.provider.ArtistRadio(ctx, id, count) if err != nil { return nil, err } @@ -397,7 +397,7 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis if artist.PlayCount > 0 { dir.Played = artist.PlayDate } - dir.AlbumCount = int32(artist.AlbumCount) + dir.AlbumCount = getArtistAlbumCount(artist) dir.UserRating = int32(artist.Rating) if artist.Starred { dir.Starred = artist.StarredAt diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 4ab4f9642..656973a4b 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -108,14 +108,13 @@ func SongsByRandom(genre string, fromYear, toYear int) Options { return addDefaultFilters(options) } -func SongWithLyrics(artist, title string) Options { +func SongsByArtistTitleWithLyricsFirst(artist, title string) Options { return addDefaultFilters(Options{ - Sort: "updated_at", + Sort: "lyrics, updated_at", Order: "desc", Max: 1, Filters: And{ Eq{"title": title}, - NotEq{"lyrics": "[]"}, Or{ persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 39f324654..58834587d 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -77,18 +77,16 @@ func sortName(sortName, orderName string) string { return orderName } -func getArtistAlbumCount(a model.Artist) int32 { - albumStats := a.Stats[model.RoleAlbumArtist] - +func getArtistAlbumCount(a *model.Artist) int32 { // If ArtistParticipations are set, then `getArtist` will return albums - // where the artist is an album artist OR artist. While it may be an underestimate, - // guess the count by taking a max of the album artist and artist count. This is - // guaranteed to be <= the actual count. + // where the artist is an album artist OR artist. Use the custom stat + // main credit for this calculation. // Otherwise, return just the roles as album artist (precise) if conf.Server.Subsonic.ArtistParticipations { - artistStats := a.Stats[model.RoleArtist] - return int32(max(artistStats.AlbumCount, albumStats.AlbumCount)) + mainCreditStats := a.Stats[model.RoleMainCredit] + return int32(mainCreditStats.AlbumCount) } else { + albumStats := a.Stats[model.RoleAlbumArtist] return int32(albumStats.AlbumCount) } } @@ -111,7 +109,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ Id: a.ID, Name: a.Name, - AlbumCount: getArtistAlbumCount(a), + AlbumCount: getArtistAlbumCount(&a), CoverArt: a.CoverArtID().String(), ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), UserRating: int32(a.Rating), diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index d703607ba..a4978237b 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -145,7 +145,7 @@ var _ = Describe("helpers", func() { model.RoleAlbumArtist: { AlbumCount: 3, }, - model.RoleArtist: { + model.RoleMainCredit: { AlbumCount: 4, }, }, @@ -153,13 +153,13 @@ var _ = Describe("helpers", func() { It("Handles album count without artist participations", func() { conf.Server.Subsonic.ArtistParticipations = false - result := getArtistAlbumCount(artist) + result := getArtistAlbumCount(&artist) Expect(result).To(Equal(int32(3))) }) It("Handles album count without with participations", func() { conf.Server.Subsonic.ArtistParticipations = true - result := getArtistAlbumCount(artist) + result := getArtistAlbumCount(&artist) Expect(result).To(Equal(int32(4))) }) }) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 74000856f..39bc83fa9 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -165,6 +165,7 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids)) } submission := p.BoolOr("submission", true) + position := p.IntOr("position", 0) ctx := r.Context() if submission { @@ -173,7 +174,7 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) } } else { - err := api.scrobblerNowPlaying(ctx, ids[0]) + err := api.scrobblerNowPlaying(ctx, ids[0], position) if err != nil { log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) } @@ -198,7 +199,7 @@ func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []ti return api.scrobbler.Submit(ctx, submissions) } -func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string) error { +func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error { mf, err := api.ds.MediaFile(ctx).Get(trackId) if err != nil { return err @@ -215,7 +216,7 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string) erro clientId = player.ID } - log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId) + log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) + err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) return err } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1611250d9..c7a8937fc 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil) }) Describe("Scrobble", func() { @@ -104,7 +104,7 @@ type fakePlayTracker struct { Error error } -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string) error { +func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { if f.Error != nil { return f.Error } diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 5cca74c30..a72e4865f 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -98,7 +98,7 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { response := newResponse() lyricsResponse := responses.Lyrics{} response.Lyrics = &lyricsResponse - mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongWithLyrics(artist, title)) + mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) if err != nil { return nil, err diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index a0e9754ce..351b4e591 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -2,17 +2,18 @@ package subsonic import ( "bytes" + "cmp" "context" "encoding/json" "errors" "io" "net/http/httptest" + "slices" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" @@ -23,7 +24,7 @@ import ( var _ = Describe("MediaRetrievalController", func() { var router *Router var ds model.DataStore - mockRepo := &mockedMediaFile{} + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} var artwork *fakeArtwork var w *httptest.ResponseRecorder @@ -31,8 +32,8 @@ var _ = Describe("MediaRetrievalController", func() { ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, } - artwork = &fakeArtwork{} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + artwork = &fakeArtwork{data: "image data"} + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" @@ -40,27 +41,27 @@ var _ = Describe("MediaRetrievalController", func() { Describe("GetCoverArt", func() { It("should return data for that id", func() { - artwork.data = "image data" - r := newGetRequest("id=34", "size=128") + r := newGetRequest("id=34", "size=128", "square=true") _, err := router.GetCoverArt(w, r) - Expect(err).To(BeNil()) - Expect(artwork.recvId).To(Equal("34")) + Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvSize).To(Equal(128)) + Expect(artwork.recvSquare).To(BeTrue()) Expect(w.Body.String()).To(Equal(artwork.data)) }) It("should return placeholder if id parameter is missing (mimicking Subsonic)", func() { - r := newGetRequest() + r := newGetRequest() // No id parameter _, err := router.GetCoverArt(w, r) Expect(err).To(BeNil()) + Expect(artwork.recvId).To(BeEmpty()) Expect(w.Body.String()).To(Equal(artwork.data)) }) It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound - r := newGetRequest("id=34", "size=128") + r := newGetRequest("id=34", "size=128", "square=true") _, err := router.GetCoverArt(w, r) Expect(err).To(MatchError("Artwork not found")) @@ -73,6 +74,45 @@ var _ = Describe("MediaRetrievalController", func() { Expect(err).To(MatchError("weird error")) }) + + When("client disconnects (context is cancelled)", func() { + It("should not call the service if cancelled before the call", func() { + // Create a request + ctx, cancel := context.WithCancel(context.Background()) + r := newGetRequest("id=34", "size=128", "square=true") + r = r.WithContext(ctx) + cancel() // Cancel the context before the call + + // Call the GetCoverArt method + _, err := router.GetCoverArt(w, r) + + // Expect no error and no call to the artwork service + Expect(err).ToNot(HaveOccurred()) + Expect(artwork.recvId).To(Equal("")) + Expect(artwork.recvSize).To(Equal(0)) + Expect(artwork.recvSquare).To(BeFalse()) + Expect(w.Body.String()).To(BeEmpty()) + }) + + It("should not return data if cancelled during the call", func() { + // Create a request with a context that will be cancelled + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Ensure the context is cancelled after the test (best practices) + r := newGetRequest("id=34", "size=128", "square=true") + r = r.WithContext(ctx) + artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service + + // Call the GetCoverArt method + _, err := router.GetCoverArt(w, r) + + // Expect no error and the service to have been called + Expect(err).ToNot(HaveOccurred()) + Expect(artwork.recvId).To(Equal("34")) + Expect(artwork.recvSize).To(Equal(128)) + Expect(artwork.recvSquare).To(BeTrue()) + Expect(w.Body.String()).To(BeEmpty()) + }) + }) }) Describe("GetLyrics", func() { @@ -84,19 +124,32 @@ var _ = Describe("MediaRetrievalController", func() { }) Expect(err).ToNot(HaveOccurred()) + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) mockRepo.SetData(model.MediaFiles{ { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), + ID: "2", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer + }, + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older + }, + { + ID: "3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest }, }) response, err := router.GetLyrics(r) - if err != nil { - log.Error("You're missing something.", err) - } - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Lyrics.Artist).To(Equal("Rick Astley")) Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) @@ -105,10 +158,7 @@ var _ = Describe("MediaRetrievalController", func() { r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") mockRepo.SetData(model.MediaFiles{}) response, err := router.GetLyrics(r) - if err != nil { - log.Error("You're missing something.", err) - } - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Lyrics.Artist).To(Equal("")) Expect(response.Lyrics.Title).To(Equal("")) Expect(response.Lyrics.Value).To(Equal("")) @@ -122,16 +172,22 @@ var _ = Describe("MediaRetrievalController", func() { Artist: "Rick Astley", Title: "Never Gonna Give You Up", }, + { + Path: "tests/fixtures/test.mp3", + ID: "2", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + }, }) response, err := router.GetLyrics(r) - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Lyrics.Artist).To(Equal("Rick Astley")) Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) }) }) - Describe("getLyricsBySongId", func() { + Describe("GetLyricsBySongId", func() { const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" @@ -271,10 +327,12 @@ var _ = Describe("MediaRetrievalController", func() { type fakeArtwork struct { artwork.Artwork - data string - err error - recvId string - recvSize int + data string + err error + ctxCancelFunc func() + recvId string + recvSize int + recvSquare bool } func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { @@ -283,27 +341,39 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, s } c.recvId = id c.recvSize = size + c.recvSquare = square + if c.ctxCancelFunc != nil { + c.ctxCancelFunc() // Simulate context cancellation + return nil, time.Time{}, context.Canceled + } return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil } type mockedMediaFile struct { - model.MediaFileRepository - data model.MediaFiles + tests.MockMediaFileRepo } -func (m *mockedMediaFile) SetData(mfs model.MediaFiles) { - m.data = mfs -} - -func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) { - return m.data, nil -} - -func (m *mockedMediaFile) Get(id string) (*model.MediaFile, error) { - for _, mf := range m.data { - if mf.ID == id { - return &mf, nil - } +func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) { + data, err := m.MockMediaFileRepo.GetAll(opts...) + if err != nil { + return nil, err } - return nil, model.ErrNotFound + if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" { + return data, nil + } + + // Hardcoded support for lyrics sorting + result := slices.Clone(data) + // Sort by presence of lyrics, then by updated_at. Respect the order specified in opts. + slices.SortFunc(result, func(a, b model.MediaFile) int { + diff := cmp.Or( + cmp.Compare(a.Lyrics, b.Lyrics), + cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()), + ) + if opts[0].Order == "desc" { + return -diff + } + return diff + }) + return result, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 04c484791..b8f01c83e 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -11,17 +11,21 @@ import ( "net/http" "net/url" "strings" + "time" + "github.com/go-chi/chi/v5/middleware" ua "github.com/mileusna/useragent" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -46,9 +50,9 @@ func postFormToQueryParams(next http.Handler) http.Handler { func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requiredParameters []string - var username string - if username = server.UsernameFromReverseProxyHeader(r); username != "" { + username := cmp.Or(server.InternalAuth(r), server.UsernameFromReverseProxyHeader(r)) + if username != "" { requiredParameters = []string{"v", "c"} } else { requiredParameters = []string{"u", "v", "c"} @@ -87,16 +91,19 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { var usr *model.User var err error - if username := server.UsernameFromReverseProxyHeader(r); username != "" { + internalAuth := server.InternalAuth(r) + proxyAuth := server.UsernameFromReverseProxyHeader(r) + if username := cmp.Or(internalAuth, proxyAuth); username != "" { + authType := If(internalAuth != "", "internal", "reverse-proxy") usr, err = ds.User(ctx).FindByUsername(username) if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "reverse-proxy", "username", username, "remoteAddr", r.RemoteAddr, err) + log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) return } if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "API: Invalid login", "auth", "reverse-proxy", "username", username, "remoteAddr", r.RemoteAddr, err) + log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) } else if err != nil { - log.Error(ctx, "API: Error authenticating username", "auth", "reverse-proxy", "username", username, "remoteAddr", r.RemoteAddr, err) + log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) } } else { p := req.Params(r) @@ -218,3 +225,37 @@ func playerIDCookieName(userName string) string { cookieName := fmt.Sprintf("nd-player-%x", userName) return cookieName } + +const subsonicErrorPointer = "subsonicErrorPointer" + +func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + status := int32(-1) + contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status) + + start := time.Now() + defer func() { + elapsed := time.Since(start).Milliseconds() + + // We want to get the client name (even if not present for certain endpoints) + p := req.Params(r) + client, _ := p.String("c") + + // If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP + if status == -1 { + status = int32(ww.Status()) + } + + shortPath := strings.Replace(r.URL.Path, ".view", "", 1) + + metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed) + }() + + next.ServeHTTP(ww, r.WithContext(contextWithStatus)) + } + return http.HandlerFunc(fn) + } +} diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 3fe577fad..a30d5b3af 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -281,6 +281,31 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeFalse()) }) }) + + When("using internal authentication", func() { + It("passes authentication with correct internal credentials", func() { + // Simulate internal authentication by setting the context with WithInternalAuth + r := newGetRequest() + r = r.WithContext(request.WithInternalAuth(r.Context(), "admin")) + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + user, _ := request.UserFrom(next.req.Context()) + Expect(user.UserName).To(Equal("admin")) + }) + + It("fails authentication with missing internal context", func() { + r := newGetRequest("u=admin") + // Do not set the internal auth context + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) + + // Internal auth requires the context, so this should fail + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) + Expect(next.called).To(BeFalse()) + }) + }) }) Describe("GetPlayer", func() { diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index d92ea4c67..3cc680afe 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ) BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 555c9eb48..83b0408ff 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -131,14 +131,8 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) if s, err := p.String("name"); err == nil { plsName = &s } - var comment *string - if s, err := p.String("comment"); err == nil { - comment = &s - } - var public *bool - if p, err := p.Bool("public"); err == nil { - public = &p - } + comment := p.StringPtr("comment") + public := p.BoolPtr("public") log.Debug(r, "Updating playlist", "id", playlistId) if plsName != nil { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go new file mode 100644 index 000000000..c0a007d6a --- /dev/null +++ b/server/subsonic/playlists_test.go @@ -0,0 +1,88 @@ +package subsonic + +import ( + "context" + + "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 _ core.Playlists = (*fakePlaylists)(nil) + +var _ = Describe("UpdatePlaylist", func() { + var router *Router + var ds model.DataStore + var playlists *fakePlaylists + + BeforeEach(func() { + ds = &tests.MockDataStore{} + playlists = &fakePlaylists{} + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil) + }) + + It("clears the comment when parameter is empty", func() { + r := newGetRequest("playlistId=123", "comment=") + _, err := router.UpdatePlaylist(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playlists.lastPlaylistID).To(Equal("123")) + Expect(playlists.lastComment).ToNot(BeNil()) + Expect(*playlists.lastComment).To(Equal("")) + }) + + It("leaves comment unchanged when parameter is missing", func() { + r := newGetRequest("playlistId=123") + _, err := router.UpdatePlaylist(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playlists.lastPlaylistID).To(Equal("123")) + Expect(playlists.lastComment).To(BeNil()) + }) + + It("sets public to true when parameter is 'true'", func() { + r := newGetRequest("playlistId=123", "public=true") + _, err := router.UpdatePlaylist(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playlists.lastPlaylistID).To(Equal("123")) + Expect(playlists.lastPublic).ToNot(BeNil()) + Expect(*playlists.lastPublic).To(BeTrue()) + }) + + It("sets public to false when parameter is 'false'", func() { + r := newGetRequest("playlistId=123", "public=false") + _, err := router.UpdatePlaylist(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playlists.lastPlaylistID).To(Equal("123")) + Expect(playlists.lastPublic).ToNot(BeNil()) + Expect(*playlists.lastPublic).To(BeFalse()) + }) + + It("leaves public unchanged when parameter is missing", func() { + r := newGetRequest("playlistId=123") + _, err := router.UpdatePlaylist(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playlists.lastPlaylistID).To(Equal("123")) + Expect(playlists.lastPublic).To(BeNil()) + }) +}) + +type fakePlaylists struct { + core.Playlists + lastPlaylistID string + lastName *string + lastComment *string + lastPublic *bool + lastAdd []string + lastRemove []int +} + +func (f *fakePlaylists) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error { + f.lastPlaylistID = playlistID + f.lastName = name + f.lastComment = comment + f.lastPublic = public + f.lastAdd = idsToAdd + f.lastRemove = idxToRemove + return nil +} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 78b5c6e7a..c2a29b22a 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -166,6 +166,52 @@ ], "displayComposer": "composer 1 \u0026 composer 2", "explicitStatus": "clean" + }, + { + "id": "2", + "isDir": true, + "title": "title", + "album": "album", + "artist": "artist", + "track": 1, + "year": 1985, + "genre": "Rock", + "coverArt": "1", + "size": 8421341, + "contentType": "audio/flac", + "suffix": "flac", + "starred": "2016-03-02T20:30:00Z", + "transcodedContentType": "audio/mpeg", + "transcodedSuffix": "mp3", + "duration": 146, + "bitRate": 320, + "isVideo": false, + "bpm": 0, + "comment": "", + "sortName": "", + "mediaType": "", + "musicBrainzId": "", + "isrc": [], + "genres": [], + "replayGain": { + "trackGain": 0, + "albumGain": 0, + "trackPeak": 0, + "albumPeak": 0, + "baseGain": 0, + "fallbackGain": 0 + }, + "channelCount": 0, + "samplingRate": 0, + "bitDepth": 0, + "moods": [], + "artists": [], + "displayArtist": "", + "albumArtists": [], + "displayAlbumArtist": "", + "contributors": [], + "displayComposer": "", + "explicitStatus": "" } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index f3281d9ee..1ad3e600c 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -33,5 +33,8 @@ <artist id="2" name="artist2"></artist> </contributors> </song> + <song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false"> + <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> + </song> </album> </subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index d64ae9e7f..fde40646a 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -112,6 +112,37 @@ ], "displayComposer": "composer 1 \u0026 composer 2", "explicitStatus": "clean" + }, + { + "id": "", + "isDir": false, + "isVideo": false, + "bpm": 0, + "comment": "", + "sortName": "", + "mediaType": "", + "musicBrainzId": "", + "isrc": [], + "genres": [], + "replayGain": { + "trackGain": 0, + "albumGain": 0, + "trackPeak": 0, + "albumPeak": 0, + "baseGain": 0, + "fallbackGain": 0 + }, + "channelCount": 0, + "samplingRate": 0, + "bitDepth": 0, + "moods": [], + "artists": [], + "displayArtist": "", + "albumArtists": [], + "displayAlbumArtist": "", + "contributors": [], + "displayComposer": "", + "explicitStatus": "" } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 639fd3f60..faea8ee93 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -25,5 +25,8 @@ <artist id="4" name="composer2"></artist> </contributors> </child> + <child id="" isDir="false" isVideo="false"> + <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> + </child> </directory> </subsonic-response> diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 4a7ebbe83..ffda2aa43 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -546,16 +546,16 @@ type ItemGenre struct { } type ReplayGain struct { - TrackGain float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` - AlbumGain float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` - TrackPeak float64 `xml:"trackPeak,omitempty,attr" json:"trackPeak,omitempty"` - AlbumPeak float64 `xml:"albumPeak,omitempty,attr" json:"albumPeak,omitempty"` - BaseGain float64 `xml:"baseGain,omitempty,attr" json:"baseGain,omitempty"` - FallbackGain float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"` + TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` + AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` + TrackPeak *float64 `xml:"trackPeak,omitempty,attr" json:"trackPeak,omitempty"` + AlbumPeak *float64 `xml:"albumPeak,omitempty,attr" json:"albumPeak,omitempty"` + BaseGain *float64 `xml:"baseGain,omitempty,attr" json:"baseGain,omitempty"` + FallbackGain *float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"` } func (r ReplayGain) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - if r.TrackGain == 0 && r.AlbumGain == 0 && r.TrackPeak == 0 && r.AlbumPeak == 0 && r.BaseGain == 0 && r.FallbackGain == 0 { + if r.TrackGain == nil && r.AlbumGain == nil && r.TrackPeak == nil && r.AlbumPeak == nil && r.BaseGain == nil && r.FallbackGain == nil { return nil } type replayGain ReplayGain diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 9fcd6078e..7238665cf 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/consts" . "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -213,7 +214,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} - child := make([]Child, 1) + child := make([]Child, 2) t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, @@ -227,7 +228,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1", "ISRC-2"}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, - ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, + ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -247,6 +248,9 @@ var _ = Describe("Responses", func() { }, ExplicitStatus: "clean", } + child[1].OpenSubsonicChild = &OpenSubsonicChild{ + ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + } response.Directory.Child = child }) @@ -309,13 +313,18 @@ var _ = Describe("Responses", func() { Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", Duration: 146, BitRate: 320, Starred: &t, + }, { + Id: "2", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, + Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", + Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", + Duration: 146, BitRate: 320, Starred: &t, }} songs[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, - ReplayGain: ReplayGain{TrackGain: 1, AlbumGain: 2, TrackPeak: 3, AlbumPeak: 4, BaseGain: 5, FallbackGain: 6}, + ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -334,6 +343,9 @@ var _ = Describe("Responses", func() { DisplayComposer: "composer 1 & composer 2", ExplicitStatus: "clean", } + songs[1].OpenSubsonicChild = &OpenSubsonicChild{ + ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs }) diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 0499b5ee0..39214eee2 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -4,26 +4,40 @@ import ( "net/http" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" ) +// buildUserResponse creates a User response object from a User model +func buildUserResponse(user model.User) responses.User { + userResponse := responses.User{ + Username: user.UserName, + AdminRole: user.IsAdmin, + Email: user.Email, + StreamRole: true, + ScrobblingEnabled: true, + DownloadRole: conf.Server.EnableDownloads, + ShareRole: conf.Server.EnableSharing, + } + + if conf.Server.Jukebox.Enabled { + userResponse.JukeboxRole = !conf.Server.Jukebox.AdminOnly || user.IsAdmin + } + + return userResponse +} + // TODO This is a placeholder. The real one has to read this info from a config file or the database func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { loggedUser, ok := request.UserFrom(r.Context()) if !ok { return nil, newError(responses.ErrorGeneric, "Internal error") } + response := newResponse() - response.User = &responses.User{} - response.User.Username = loggedUser.UserName - response.User.AdminRole = loggedUser.IsAdmin - response.User.Email = loggedUser.Email - response.User.StreamRole = true - response.User.ScrobblingEnabled = true - response.User.DownloadRole = conf.Server.EnableDownloads - response.User.ShareRole = conf.Server.EnableSharing - response.User.JukeboxRole = conf.Server.Jukebox.Enabled + user := buildUserResponse(loggedUser) + response.User = &user return response, nil } @@ -32,17 +46,8 @@ func (api *Router) GetUsers(r *http.Request) (*responses.Subsonic, error) { if !ok { return nil, newError(responses.ErrorGeneric, "Internal error") } - user := responses.User{} - user.Username = loggedUser.Name - user.AdminRole = loggedUser.IsAdmin - user.Email = loggedUser.Email - user.StreamRole = true - user.ScrobblingEnabled = true - user.DownloadRole = conf.Server.EnableDownloads - user.ShareRole = conf.Server.EnableSharing - if conf.Server.Jukebox.Enabled { - user.JukeboxRole = !conf.Server.Jukebox.AdminOnly || loggedUser.IsAdmin - } + + user := buildUserResponse(loggedUser) response := newResponse() response.Users = &responses.Users{User: []responses.User{user}} return response, nil diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go new file mode 100644 index 000000000..d08462290 --- /dev/null +++ b/server/subsonic/users_test.go @@ -0,0 +1,96 @@ +package subsonic + +import ( + "context" + "net/http/httptest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Users", func() { + var router *Router + var testUser model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + router = &Router{} + + testUser = model.User{ + ID: "user123", + UserName: "testuser", + Name: "Test User", + Email: "test@example.com", + IsAdmin: false, + } + }) + + Describe("Happy path", func() { + It("should return consistent user data in both GetUser and GetUsers", func() { + conf.Server.EnableDownloads = true + conf.Server.EnableSharing = true + conf.Server.Jukebox.Enabled = false + + // Create request with user in context + req := httptest.NewRequest("GET", "/rest/getUser", nil) + ctx := request.WithUser(context.Background(), testUser) + req = req.WithContext(ctx) + + userResponse, err1 := router.GetUser(req) + usersResponse, err2 := router.GetUsers(req) + + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + + // Verify GetUser response structure + Expect(userResponse.Status).To(Equal(responses.StatusOK)) + Expect(userResponse.User).ToNot(BeNil()) + Expect(userResponse.User.Username).To(Equal("testuser")) + Expect(userResponse.User.Email).To(Equal("test@example.com")) + Expect(userResponse.User.AdminRole).To(BeFalse()) + Expect(userResponse.User.StreamRole).To(BeTrue()) + Expect(userResponse.User.ScrobblingEnabled).To(BeTrue()) + Expect(userResponse.User.DownloadRole).To(BeTrue()) + Expect(userResponse.User.ShareRole).To(BeTrue()) + + // Verify GetUsers response structure + Expect(usersResponse.Status).To(Equal(responses.StatusOK)) + Expect(usersResponse.Users).ToNot(BeNil()) + Expect(usersResponse.Users.User).To(HaveLen(1)) + + // Verify both methods return identical user data + singleUser := userResponse.User + userFromList := &usersResponse.Users.User[0] + + Expect(singleUser.Username).To(Equal(userFromList.Username)) + Expect(singleUser.Email).To(Equal(userFromList.Email)) + Expect(singleUser.AdminRole).To(Equal(userFromList.AdminRole)) + Expect(singleUser.StreamRole).To(Equal(userFromList.StreamRole)) + Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled)) + Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole)) + Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole)) + Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole)) + }) + }) + + DescribeTable("Jukebox role permissions", + func(jukeboxEnabled, adminOnly, isAdmin, expectedJukeboxRole bool) { + conf.Server.Jukebox.Enabled = jukeboxEnabled + conf.Server.Jukebox.AdminOnly = adminOnly + testUser.IsAdmin = isAdmin + + response := buildUserResponse(testUser) + Expect(response.JukeboxRole).To(Equal(expectedJukeboxRole)) + }, + Entry("jukebox disabled", false, false, false, false), + Entry("jukebox enabled, not admin-only, regular user", true, false, false, true), + Entry("jukebox enabled, not admin-only, admin user", true, false, true, true), + Entry("jukebox enabled, admin-only, regular user", true, true, false, false), + Entry("jukebox enabled, admin-only, admin user", true, true, true, true), + ) +}) diff --git a/tests/fixtures/deezer.search.artist.json b/tests/fixtures/deezer.search.artist.json new file mode 100644 index 000000000..29f138d34 --- /dev/null +++ b/tests/fixtures/deezer.search.artist.json @@ -0,0 +1 @@ +{"data":[{"id":259,"name":"Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/259","picture":"https:\/\/api.deezer.com\/artist\/259\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/1000x1000-000000-80-0-0.jpg","nb_album":43,"nb_fan":12074101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/259\/top?limit=50","type":"artist"},{"id":719,"name":"Bob Marley & The Wailers","link":"https:\/\/www.deezer.com\/artist\/719","picture":"https:\/\/api.deezer.com\/artist\/719\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/1000x1000-000000-80-0-0.jpg","nb_album":80,"nb_fan":12014466,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/719\/top?limit=50","type":"artist"},{"id":14031649,"name":"jay emcee, Micheal Jackson","link":"https:\/\/www.deezer.com\/artist\/14031649","picture":"https:\/\/api.deezer.com\/artist\/14031649\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":104,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/14031649\/top?limit=50","type":"artist"},{"id":137159102,"name":"Micheal Collins The Mic Jackson Of Rap","link":"https:\/\/www.deezer.com\/artist\/137159102","picture":"https:\/\/api.deezer.com\/artist\/137159102\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":13,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/137159102\/top?limit=50","type":"artist"},{"id":259786511,"name":"Consev","link":"https:\/\/www.deezer.com\/artist\/259786511","picture":"https:\/\/api.deezer.com\/artist\/259786511\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":1,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/259786511\/top?limit=50","type":"artist"},{"id":262255,"name":"Michael Jackson Tribute","link":"https:\/\/www.deezer.com\/artist\/262255","picture":"https:\/\/api.deezer.com\/artist\/262255\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":9339,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/262255\/top?limit=50","type":"artist"},{"id":193820797,"name":"Michael Jackman","link":"https:\/\/www.deezer.com\/artist\/193820797","picture":"https:\/\/api.deezer.com\/artist\/193820797\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":0,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/193820797\/top?limit=50","type":"artist"},{"id":374060,"name":"Simply The Best Sax: The Hits Of Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/374060","picture":"https:\/\/api.deezer.com\/artist\/374060\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":1507,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/374060\/top?limit=50","type":"artist"},{"id":4969823,"name":"Jackson Michael","link":"https:\/\/www.deezer.com\/artist\/4969823","picture":"https:\/\/api.deezer.com\/artist\/4969823\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":17,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4969823\/top?limit=50","type":"artist"},{"id":1278001,"name":"David Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/1278001","picture":"https:\/\/api.deezer.com\/artist\/1278001\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/1000x1000-000000-80-0-0.jpg","nb_album":54,"nb_fan":178,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1278001\/top?limit=50","type":"artist"},{"id":4142968,"name":"Cheyenne Jackson, Michael Feinstein","link":"https:\/\/www.deezer.com\/artist\/4142968","picture":"https:\/\/api.deezer.com\/artist\/4142968\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":251,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4142968\/top?limit=50","type":"artist"},{"id":766502,"name":"Michael Jackson Tribute Band","link":"https:\/\/www.deezer.com\/artist\/766502","picture":"https:\/\/api.deezer.com\/artist\/766502\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":623,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/766502\/top?limit=50","type":"artist"},{"id":1394615,"name":"Michael Jameson","link":"https:\/\/www.deezer.com\/artist\/1394615","picture":"https:\/\/api.deezer.com\/artist\/1394615\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":78,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1394615\/top?limit=50","type":"artist"},{"id":490836,"name":"Michael Blackson","link":"https:\/\/www.deezer.com\/artist\/490836","picture":"https:\/\/api.deezer.com\/artist\/490836\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":391,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/490836\/top?limit=50","type":"artist"},{"id":1229617,"name":"The Michael Jackson Tribute Band","link":"https:\/\/www.deezer.com\/artist\/1229617","picture":"https:\/\/api.deezer.com\/artist\/1229617\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":344,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1229617\/top?limit=50","type":"artist"},{"id":3662911,"name":"Fran London feat. Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/3662911","picture":"https:\/\/api.deezer.com\/artist\/3662911\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":247,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3662911\/top?limit=50","type":"artist"},{"id":13014917,"name":"Scott Michael Bennett, Naomi Jackson, Gary Sewell & The Emmanuel Quartet","link":"https:\/\/www.deezer.com\/artist\/13014917","picture":"https:\/\/api.deezer.com\/artist\/13014917\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":66,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/13014917\/top?limit=50","type":"artist"}],"total":17} \ No newline at end of file diff --git a/tests/fixtures/no_replaygain.mp3 b/tests/fixtures/no_replaygain.mp3 new file mode 100644 index 000000000..45c2176e3 Binary files /dev/null and b/tests/fixtures/no_replaygain.mp3 differ diff --git a/tests/fixtures/zero_replaygain.mp3 b/tests/fixtures/zero_replaygain.mp3 new file mode 100644 index 000000000..96e6d21f0 Binary files /dev/null and b/tests/fixtures/zero_replaygain.mp3 differ diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 7058cead0..da5851061 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -94,4 +94,18 @@ func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { return nil } +func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + +func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index f380755e0..02a03e56e 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -2,6 +2,7 @@ package tests import ( "context" + "sync" "github.com/navidrome/navidrome/model" ) @@ -19,11 +20,13 @@ type MockDataStore struct { MockedProperty model.PropertyRepository MockedPlayer model.PlayerRepository MockedPlaylist model.PlaylistRepository + MockedPlayQueue model.PlayQueueRepository MockedShare model.ShareRepository MockedTranscoding model.TranscodingRepository MockedUserProps model.UserPropsRepository MockedScrobbleBuffer model.ScrobbleBufferRepository MockedRadio model.RadioRepository + scrobbleBufferMu sync.Mutex } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { @@ -115,10 +118,14 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository } func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { - if db.RealDS != nil { - return db.RealDS.PlayQueue(ctx) + if db.MockedPlayQueue == nil { + if db.RealDS != nil { + db.MockedPlayQueue = db.RealDS.PlayQueue(ctx) + } else { + db.MockedPlayQueue = &MockPlayQueueRepo{} + } } - return struct{ model.PlayQueueRepository }{} + return db.MockedPlayQueue } func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { @@ -188,6 +195,8 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { } func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + db.scrobbleBufferMu.Lock() + defer db.scrobbleBufferMu.Unlock() if db.MockedScrobbleBuffer == nil { if db.RealDS != nil { db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx) @@ -217,8 +226,33 @@ func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, l return block(db) } -func (db *MockDataStore) Resource(context.Context, any) model.ResourceRepository { - return struct{ model.ResourceRepository }{} +func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepository { + switch m.(type) { + case model.MediaFile, *model.MediaFile: + return db.MediaFile(ctx).(model.ResourceRepository) + case model.Album, *model.Album: + return db.Album(ctx).(model.ResourceRepository) + case model.Artist, *model.Artist: + return db.Artist(ctx).(model.ResourceRepository) + case model.User, *model.User: + return db.User(ctx).(model.ResourceRepository) + case model.Playlist, *model.Playlist: + return db.Playlist(ctx).(model.ResourceRepository) + case model.Radio, *model.Radio: + return db.Radio(ctx).(model.ResourceRepository) + case model.Share, *model.Share: + return db.Share(ctx).(model.ResourceRepository) + case model.Genre, *model.Genre: + return db.Genre(ctx).(model.ResourceRepository) + case model.Tag, *model.Tag: + return db.Tag(ctx).(model.ResourceRepository) + case model.Transcoding, *model.Transcoding: + return db.Transcoding(ctx).(model.ResourceRepository) + case model.Player, *model.Player: + return db.Player(ctx).(model.ResourceRepository) + default: + return struct{ model.ResourceRepository }{} + } } func (db *MockDataStore) GC(context.Context) error { diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 907a9d487..7cc8b02f7 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -35,4 +35,8 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } +func (m *MockLibraryRepo) RefreshStats(id int) error { + return nil +} + var _ model.LibraryRepository = &MockLibraryRepo{} diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 01d82e03b..7bba8eda8 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -7,6 +7,7 @@ import ( "slices" "time" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" @@ -22,6 +23,10 @@ type MockMediaFileRepo struct { model.MediaFileRepository Data map[string]*model.MediaFile Err bool + // Add fields and methods for controlling CountAll and DeleteAllMissing in tests + CountAllValue int64 + CountAllOptions model.QueryOptions + DeleteAllMissingValue int64 } func (m *MockMediaFileRepo) SetError(err bool) { @@ -72,9 +77,14 @@ func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, err return nil, errors.New("error") } values := slices.Collect(maps.Values(m.Data)) - return slice.Map(values, func(p *model.MediaFile) model.MediaFile { + result := slice.Map(values, func(p *model.MediaFile) model.MediaFile { return *p - }), nil + }) + // Sort by ID to ensure deterministic ordering for tests + slices.SortFunc(result, func(a, b model.MediaFile) int { + return cmp.Compare(a.ID, b.ID) + }) + return result, nil } func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { @@ -161,4 +171,61 @@ func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCur }, nil } +func (m *MockMediaFileRepo) CountAll(opts ...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("error") + } + if m.CountAllValue != 0 { + if len(opts) > 0 { + m.CountAllOptions = opts[0] + } + return m.CountAllValue, nil + } + return int64(len(m.Data)), nil +} + +func (m *MockMediaFileRepo) DeleteAllMissing() (int64, error) { + if m.Err { + return 0, errors.New("error") + } + if m.DeleteAllMissingValue != 0 { + return m.DeleteAllMissingValue, nil + } + // Remove all missing files from Data + var count int64 + for id, mf := range m.Data { + if mf.Missing { + delete(m.Data, id) + count++ + } + } + return count, nil +} + +// ResourceRepository methods +func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { + return m.CountAll() +} + +func (m *MockMediaFileRepo) Read(id string) (interface{}, error) { + mf, err := m.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return mf, err +} + +func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (interface{}, error) { + return m.GetAll() +} + +func (m *MockMediaFileRepo) EntityName() string { + return "mediafile" +} + +func (m *MockMediaFileRepo) NewInstance() interface{} { + return &model.MediaFile{} +} + var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) +var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_playqueue_repo.go b/tests/mock_playqueue_repo.go new file mode 100644 index 000000000..19976db57 --- /dev/null +++ b/tests/mock_playqueue_repo.go @@ -0,0 +1,65 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" +) + +type MockPlayQueueRepo struct { + model.PlayQueueRepository + Queue *model.PlayQueue + Err bool + LastCols []string +} + +func (m *MockPlayQueueRepo) Store(q *model.PlayQueue, cols ...string) error { + if m.Err { + return errors.New("error") + } + copyItems := make(model.MediaFiles, len(q.Items)) + copy(copyItems, q.Items) + qCopy := *q + qCopy.Items = copyItems + m.Queue = &qCopy + m.LastCols = cols + return nil +} + +func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { + if m.Err { + return nil, errors.New("error") + } + if m.Queue == nil || m.Queue.UserID != userId { + return nil, model.ErrNotFound + } + copyItems := make(model.MediaFiles, len(m.Queue.Items)) + copy(copyItems, m.Queue.Items) + qCopy := *m.Queue + qCopy.Items = copyItems + return &qCopy, nil +} + +func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) { + if m.Err { + return nil, errors.New("error") + } + if m.Queue == nil || m.Queue.UserID != userId { + return nil, model.ErrNotFound + } + copyItems := make(model.MediaFiles, len(m.Queue.Items)) + for i, t := range m.Queue.Items { + copyItems[i] = model.MediaFile{ID: t.ID} + } + qCopy := *m.Queue + qCopy.Items = copyItems + return &qCopy, nil +} + +func (m *MockPlayQueueRepo) Clear(userId string) error { + if m.Err { + return errors.New("error") + } + m.Queue = nil + return nil +} diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 407c673eb..5865f423a 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -1,6 +1,7 @@ package tests import ( + "sync" "time" "github.com/navidrome/navidrome/model" @@ -9,6 +10,7 @@ import ( type MockedScrobbleBufferRepo struct { Error error Data model.ScrobbleEntries + mu sync.RWMutex } func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo { @@ -19,6 +21,8 @@ func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) { if m.Error != nil { return nil, m.Error } + m.mu.RLock() + defer m.mu.RUnlock() userIds := make(map[string]struct{}) for _, e := range m.Data { if e.Service == service { @@ -36,6 +40,8 @@ func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, if m.Error != nil { return m.Error } + m.mu.Lock() + defer m.mu.Unlock() m.Data = append(m.Data, model.ScrobbleEntry{ MediaFile: model.MediaFile{ID: mediaFileId}, Service: service, @@ -50,6 +56,8 @@ func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.Scrobble if m.Error != nil { return nil, m.Error } + m.mu.RLock() + defer m.mu.RUnlock() for _, e := range m.Data { if e.Service == service && e.UserID == userId { return &e, nil @@ -62,6 +70,8 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { if m.Error != nil { return m.Error } + m.mu.Lock() + defer m.mu.Unlock() newData := model.ScrobbleEntries{} for _, e := range m.Data { if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID { @@ -77,5 +87,7 @@ func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error } + m.mu.RLock() + defer m.mu.RUnlock() return int64(len(m.Data)), nil } diff --git a/tests/navidrome-test.toml b/tests/navidrome-test.toml index 48f9f4c38..117178a76 100644 --- a/tests/navidrome-test.toml +++ b/tests/navidrome-test.toml @@ -1,5 +1,7 @@ User = "deluan" Password = "wordpass" DbPath = "file::memory:?cache=shared" -DataFolder = "data/tests" +DataFolder = "tmp/tests" ScanSchedule="0" +Plugins.Enabled = true +Plugins.Folder = "plugins/testdata" diff --git a/ui/.eslintignore b/ui/.eslintignore index 82026e6c6..86c39adb2 100644 --- a/ui/.eslintignore +++ b/ui/.eslintignore @@ -3,4 +3,5 @@ build/ prettier.config.js .eslintrc vite.config.js -public/3rdparty/workbox \ No newline at end of file +public/3rdparty/workbox +coverage/ \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index ad03ef2ab..9e449c5e0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -7,7 +7,7 @@ "name": "ui", "hasInstallScript": true, "dependencies": { - "@material-ui/core": "^4.11.4", + "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.5", @@ -26,9 +26,9 @@ "react": "^17.0.2", "react-admin": "^3.19.12", "react-dnd": "^14.0.5", - "react-dnd-html5-backend": "^14.0.2", + "react-dnd-html5-backend": "^14.1.0", "react-dom": "^17.0.2", - "react-drag-listview": "^0.1.8", + "react-drag-listview": "^0.1.9", "react-ga": "^3.3.1", "react-hotkeys": "^2.0.0", "react-icons": "^5.5.0", @@ -37,7 +37,7 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.4", "redux": "^4.2.1", - "redux-saga": "^1.1.3", + "redux-saga": "^1.3.0", "uuid": "^11.1.0", "workbox-cli": "^7.3.0" }, @@ -46,41 +46,39 @@ "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.9", - "@types/react": "^17.0.83", + "@types/node": "^22.15.21", + "@types/react": "^17.0.86", "@types/react-dom": "^17.0.26", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^3.0.8", + "@vitejs/plugin-react": "^4.5.0", + "@vitest/coverage-v8": "^3.1.4", "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^10.1.5", "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "happy-dom": "^17.4.0", - "jsdom": "^26.0.0", + "eslint-plugin-react-refresh": "^0.4.20", + "happy-dom": "^17.4.7", + "jsdom": "^26.1.0", "prettier": "^3.5.3", "ra-test": "^3.19.12", - "typescript": "^5.8.2", - "vite": "^6.2.1", - "vite-plugin-pwa": "^0.21.1", - "vitest": "^3.0.8" + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-plugin-pwa": "^0.21.2", + "vitest": "^3.1.4" } }, "node_modules/@adobe/css-tools": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", - "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", - "dev": true, - "license": "MIT" + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -90,13 +88,13 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.3.tgz", - "integrity": "sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "dependencies": { - "@csstools/css-calc": "^2.1.1", - "@csstools/css-color-parser": "^3.0.7", + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" @@ -109,41 +107,41 @@ "dev": true }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -162,18 +160,17 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -183,37 +180,23 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.7.tgz", - "integrity": "sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", + "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", "dependencies": { - "@babel/types": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.7.tgz", - "integrity": "sha512-12xfNeKNH7jubQNm7PAkzlLwEmCs1tfuX3UjIw6vP6QXi+leKh6+LyC/+Ed4EIQermwd58wsyh070yjDHFlNGg==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -226,23 +209,21 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.7.tgz", - "integrity": "sha512-bD4WQhbkx80mAyj/WCm4ZHcF4rDxkoLFO6ph8/5/mQ3z4vAzltQXAmbc7GvVJx5H+lk5Mi5EmbTeox5nMGCsbw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-member-expression-to-functions": "^7.25.7", - "@babel/helper-optimise-call-expression": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", - "@babel/traverse": "^7.25.7", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "engines": { @@ -256,19 +237,17 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.7.tgz", - "integrity": "sha512-byHhumTj/X47wJ6C6eLpK7wW/WBEcnUeb7D0FNc/jFQnQVw7DOso3Zz5u9x/zLrFVkHa89ZGDbkAa1D54NdrCQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "regexpu-core": "^6.1.1", + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, "engines": { @@ -282,16 +261,14 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", - "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", - "license": "MIT", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", + "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -304,55 +281,56 @@ } }, "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "license": "MIT", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.7.tgz", - "integrity": "sha512-O31Ssjd5K6lPbTX9AAYpSKrZmLeagt9uwschJd+Ixo6QiRyfpvgtVQp8qrDR9UNFjZ8+DO34ZkdrN+BnPXemeA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -362,34 +340,32 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.7.tgz", - "integrity": "sha512-VAwcwuYhv/AT+Vfr28c9y6SHzTan1ryqrydSTFGjU0uDJHw3uZ+PduI8plCLkRsDnqK2DMEDmwrOQRsK/Ykjng==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "dependencies": { - "@babel/types": "^7.25.7" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", - "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.7.tgz", - "integrity": "sha512-kRGE89hLnPfcz6fTrlNU+uhgcwv0mBE4Gv3P9Ke9kLVJYpi4AMVVEElXvB5CabrPZW4nCM8P8UyyjrzCM0O2sw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-wrap-function": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -399,14 +375,13 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.7.tgz", - "integrity": "sha512-iy8JhqlUW9PtZkd4pHM96v6BdJ66Ba9yWSE4z0W4TvSZwLBPkyDsiIU3ENe4SmrzRBs76F7rQXTy1lYC49n6Lw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.7", - "@babel/helper-optimise-call-expression": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -415,88 +390,73 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-simple-access": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.7.tgz", - "integrity": "sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==", - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.7.tgz", - "integrity": "sha512-pPbNbchZBkPMD50K0p3JGcFMNLVUCuU/ABybm/PGNj4JiHrpmNyqqCphBk4i19xXtNV0JhldQJJtbSW5aUvbyA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dependencies": { - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.7.tgz", - "integrity": "sha512-MA0roW3JF2bD1ptAaJnvcabsVlNQShUaThyJbCDD4bCp8NEgiFvpoqRI2YS22hHlc2thjO/fTg2ShLMC3jygAg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", "dependencies": { - "@babel/template": "^7.25.7", - "@babel/traverse": "^7.25.7", - "@babel/types": "^7.25.7" + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.27.1" }, "bin": { "parser": "bin/babel-parser.js" @@ -506,13 +466,12 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.7.tgz", - "integrity": "sha512-UV9Lg53zyebzD1DwQoT9mzkEKa922LNUp5YkTJ6Uta0RbyXaQNUgcvSt7qIu1PpPzVb6rd10OVNTzkyBGeVmxQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -522,12 +481,11 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.7.tgz", - "integrity": "sha512-GDDWeVLNxRIkQTnJn2pDOM1pkCgYdSqPeT1a9vh9yIqu2uzzgw1zcqEb+IJOhy+dTBMlNdThrDIksr2o09qrrQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -537,12 +495,11 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.7.tgz", - "integrity": "sha512-wxyWg2RYaSUYgmd9MR0FyRGyeOMQE/Uzr1wzd/g5cf5bwi9A4v6HFdDm7y1MgDtod/fLOSTZY6jDgV0xU9d5bA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -552,14 +509,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.7.tgz", - "integrity": "sha512-Xwg6tZpLxc4iQjorYsyGMyfJE7nP5MV8t/Ka58BgiA7Jw0fRqQNcANlLfdJ/yvBt9z9LD2We+BEkT7vLqZRWng==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7", - "@babel/plugin-transform-optional-chaining": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -569,13 +525,12 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.7.tgz", - "integrity": "sha512-UVATLMidXrnH+GMUIuxq55nejlj02HP7F5ETyBONzP6G87fPBogG4CH6kxrSrdIuAjdwNO9VzyaYsrZPscWUrw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -588,7 +543,6 @@ "version": "7.21.0-placeholder-for-preset-env.2", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "license": "MIT", "engines": { "node": ">=6.9.0" }, @@ -597,12 +551,11 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.7.tgz", - "integrity": "sha512-ZvZQRmME0zfJnDQnVBKYzHxXT7lYBB3Revz1GuS7oLXWMgqUPX4G+DDbT30ICClht9WKV34QVrZhSw6WdklwZQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -612,12 +565,11 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.7.tgz", - "integrity": "sha512-AqVo+dguCgmpi/3mYBdu9lkngOBlQ2w2vnNpa6gfiCxQZLzV4ZbhsXitJ2Yblkoe1VQwtHSaNmIaGll/26YWRw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -630,7 +582,6 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -643,12 +594,11 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.7.tgz", - "integrity": "sha512-EJN2mKxDwfOUCPxMO6MUI58RN3ganiRAG/MS/S3HfB6QFNjroAMelQo/gybyYq97WerCBAZoyrAoW8Tzdq2jWg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -658,14 +608,13 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.8.tgz", - "integrity": "sha512-9ypqkozyzpG+HxlH4o4gdctalFGIjjdufzo7I2XPda0iBnZ6a+FO0rIEQcdSPXp02CkvGsII1exJhmROPQd5oA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz", + "integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-remap-async-to-generator": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -675,14 +624,13 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.7.tgz", - "integrity": "sha512-ZUCjAavsh5CESCmi/xCpX1qcCaAglzs/7tmuvoFnJgA1dM7gQplsguljoTg+Ru8WENpX89cQyAtWoaE0I3X3Pg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", "dependencies": { - "@babel/helper-module-imports": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-remap-async-to-generator": "^7.25.7" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -692,12 +640,11 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.7.tgz", - "integrity": "sha512-xHttvIM9fvqW+0a3tZlYcZYSBpSWzGBFIt/sYG3tcdSzBB8ZeVgz2gBP7Df+sM0N1850jrviYSSeUuc+135dmQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -707,12 +654,11 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.7.tgz", - "integrity": "sha512-ZEPJSkVZaeTFG/m2PARwLZQ+OG0vFIhPlKHK/JdIMy8DbRJ/htz6LRrTFtdzxi9EHmcwbNPAKDnadpNSIW+Aow==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", + "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -722,13 +668,12 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.7.tgz", - "integrity": "sha512-mhyfEW4gufjIqYFo9krXHJ3ElbFLIze5IDp+wQTxoPd+mwFb1NxatNAwmv8Q8Iuxv7Zc+q8EkiMQwc9IhyGf4g==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -738,13 +683,12 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.25.8.tgz", - "integrity": "sha512-e82gl3TCorath6YLf9xUwFehVvjvfqFhdOo4+0iVIVju+6XOi5XHkqB3P2AXnSwoeTX0HBoXq5gJFtvotJzFnQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -754,16 +698,15 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.7.tgz", - "integrity": "sha512-9j9rnl+YCQY0IGoeipXvnk3niWicIB6kCsWRGLwX241qSXpbA4MKxtp/EdvFxsc4zI5vqfLxzOd0twIJ7I99zg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", + "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7", - "@babel/traverse": "^7.25.7", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.27.1", "globals": "^11.1.0" }, "engines": { @@ -774,13 +717,12 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.7.tgz", - "integrity": "sha512-QIv+imtM+EtNxg/XBKL3hiWjgdLjMOmZ+XzQwSgmBfKbfxUjBzGgVPklUuE55eq5/uVoh8gg3dqlrwR/jw3ZeA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/template": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -790,12 +732,11 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.7.tgz", - "integrity": "sha512-xKcfLTlJYUczdaM1+epcdh1UGewJqr9zATgrNHcLBcV2QmfvPPEixo/sK/syql9cEmbr7ulu5HMFG5vbbt/sEA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", + "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -805,13 +746,12 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.7.tgz", - "integrity": "sha512-kXzXMMRzAtJdDEgQBLF4oaiT6ZCU3oWHgpARnTKDAqPkDJ+bs3NrZb310YYevR5QlRo3Kn7dzzIdHbZm1VzJdQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -821,12 +761,11 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.7.tgz", - "integrity": "sha512-by+v2CjoL3aMnWDOyCIg+yxU9KXSRa9tN6MbqggH5xvymmr9p4AMjYkNlQy4brMceBnUyHZ9G8RnpvT8wP7Cfg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -836,13 +775,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.7.tgz", - "integrity": "sha512-HvS6JF66xSS5rNKXLqkk7L9c/jZ/cdIVIcoPVrnl8IsVpLggTjXs8OWekbLHs/VtYDDh5WXnQyeE3PPUGm22MA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -852,12 +790,11 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.8.tgz", - "integrity": "sha512-gznWY+mr4ZQL/EWPcbBQUP3BXS5FwZp8RUOw06BaRn8tQLzN4XLIxXejpHN9Qo8x8jjBmAAKp6FoS51AgkSA/A==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -867,13 +804,11 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.7.tgz", - "integrity": "sha512-yjqtpstPfZ0h/y40fAXRv2snciYr0OAoMXY/0ClC7tm4C/nG5NJKmIItlaYlLbIVAWNfrYuy9dq1bE0SbX0PEg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -883,12 +818,11 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.8.tgz", - "integrity": "sha512-sPtYrduWINTQTW7FtOy99VCTWp4H23UX7vYcut7S4CIMEXU+54zKX9uCoGkLsWXteyaMXzVHgzWbLfQ1w4GZgw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -898,13 +832,12 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.7.tgz", - "integrity": "sha512-n/TaiBGJxYFWvpJDfsxSj9lEEE44BFM1EPGz4KEiTipTgkoFVVcCmzAL3qA7fdQU96dpo4gGf5HBx/KnDvqiHw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -914,14 +847,13 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.7.tgz", - "integrity": "sha512-5MCTNcjCMxQ63Tdu9rxyN6cAWurqfrDZ76qvVPrGYdBxIj+EawuuxTu/+dgJlhK5eRz3v1gLwp6XwS8XaX2NiQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -931,12 +863,11 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.8.tgz", - "integrity": "sha512-4OMNv7eHTmJ2YXs3tvxAfa/I43di+VcF+M4Wt66c88EAED1RoGaf1D64cL5FkRpNL+Vx9Hds84lksWvd/wMIdA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -946,12 +877,11 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.7.tgz", - "integrity": "sha512-fwzkLrSu2fESR/cm4t6vqd7ebNIopz2QHGtjoU+dswQo/P6lwAG04Q98lliE3jkz/XqnbGFLnUcE0q0CVUf92w==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -961,12 +891,11 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.8.tgz", - "integrity": "sha512-f5W0AhSbbI+yY6VakT04jmxdxz+WsID0neG7+kQZbCOjuyJNdL5Nn4WIBm4hRpKnUcO9lP0eipUhFN12JpoH8g==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -976,12 +905,11 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.7.tgz", - "integrity": "sha512-Std3kXwpXfRV0QtQy5JJcRpkqP8/wG4XL7hSKZmGlxPlDqmpXtEPRmhF7ztnlTCtUN3eXRUJp+sBEZjaIBVYaw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -991,13 +919,12 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.7.tgz", - "integrity": "sha512-CgselSGCGzjQvKzghCvDTxKHP3iooenLpJDO842ehn5D2G5fJB222ptnDwQho0WjEvg7zyoxb9P+wiYxiJX5yA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1007,14 +934,12 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.7.tgz", - "integrity": "sha512-L9Gcahi0kKFYXvweO6n0wc3ZG1ChpSFdgG+eV1WYZ3/dGbJK7vvk91FgGgak8YwRgrCuihF8tE/Xg07EkL5COg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-simple-access": "^7.25.7" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1024,15 +949,14 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.7.tgz", - "integrity": "sha512-t9jZIvBmOXJsiuyOwhrIGs8dVcD6jDyg2icw1VL4A/g+FnWyJKwUfSSU2nwJuMV2Zqui856El9u+ElB+j9fV1g==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-validator-identifier": "^7.25.7", - "@babel/traverse": "^7.25.7" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1042,13 +966,12 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.7.tgz", - "integrity": "sha512-p88Jg6QqsaPh+EB7I9GJrIqi1Zt4ZBHUQtjw3z1bzEXcLh6GfPqzZJ6G+G1HBGKUNukT58MnKG7EN7zXQBCODw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "dependencies": { - "@babel/helper-module-transforms": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1058,13 +981,12 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.7.tgz", - "integrity": "sha512-BtAT9LzCISKG3Dsdw5uso4oV1+v2NlVXIIomKJgQybotJY3OwCwJmkongjHgwGKoZXd0qG5UZ12JUlDQ07W6Ow==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1074,12 +996,11 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.7.tgz", - "integrity": "sha512-CfCS2jDsbcZaVYxRFo2qtavW8SpdzmBXC2LOI4oO0rP+JSRDxxF3inF4GcPsLgfb5FjkhXG5/yR/lxuRs2pySA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1089,12 +1010,11 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.8.tgz", - "integrity": "sha512-Z7WJJWdQc8yCWgAmjI3hyC+5PXIubH9yRKzkl9ZEG647O9szl9zvmKLzpbItlijBnVhTUf1cpyWBsZ3+2wjWPQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1104,12 +1024,11 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.8.tgz", - "integrity": "sha512-rm9a5iEFPS4iMIy+/A/PiS0QN0UyjPIeVvbU5EMZFKJZHt8vQnasbpo3T3EFcxzCeYO0BHfc4RqooCZc51J86Q==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1119,14 +1038,14 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.8.tgz", - "integrity": "sha512-LkUu0O2hnUKHKE7/zYOIjByMa4VRaV2CD/cdGz0AxU9we+VA3kDDggKEzI0Oz1IroG+6gUP6UmWEHBMWZU316g==", - "license": "MIT", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.2.tgz", + "integrity": "sha512-AIUHD7xJ1mCrj3uPozvtngY3s0xpv7Nu7DoUSnzNY6Xam1Cy4rUznR//pvMHOhQ4AvbCexhbqXCtpxGHOGOO6g==", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/plugin-transform-parameters": "^7.25.7" + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1136,13 +1055,12 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.7.tgz", - "integrity": "sha512-pWT6UXCEW3u1t2tcAGtE15ornCBvopHj9Bps9D2DsH15APgNVOTwwczGckX+WkAvBmuoYKRCFa4DK+jM8vh5AA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-replace-supers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1152,12 +1070,11 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.8.tgz", - "integrity": "sha512-EbQYweoMAHOn7iJ9GgZo14ghhb9tTjgOc88xFgYngifx7Z9u580cENCV159M4xDh3q/irbhSjZVpuhpC2gKBbg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1167,13 +1084,12 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.8.tgz", - "integrity": "sha512-q05Bk7gXOxpTHoQ8RSzGSh/LHVB9JEIkKnk3myAWwZHnYiTGYtbdrYkIsS8Xyh4ltKf7GNUSgzs/6P2bJtBAQg==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1183,12 +1099,11 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.7.tgz", - "integrity": "sha512-FYiTvku63me9+1Nz7TOx4YMtW3tWXzfANZtrzHhUZrz4d47EEtMQhzFoZWESfXuAMMT5mwzD4+y1N8ONAX6lMQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", + "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1198,13 +1113,12 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.7.tgz", - "integrity": "sha512-KY0hh2FluNxMLwOCHbxVOKfdB5sjWG4M183885FmaqWWiGMhRZq4DQRKH6mHdEucbJnyDyYiZNwNG424RymJjA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1214,14 +1128,13 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.8.tgz", - "integrity": "sha512-8Uh966svuB4V8RHHg0QJOB32QK287NBksJOByoKmHMp1TAobNniNalIkI2i5IPj5+S9NYCG4VIjbEuiSN8r+ow==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.7", - "@babel/helper-create-class-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1231,12 +1144,11 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.7.tgz", - "integrity": "sha512-lQEeetGKfFi0wHbt8ClQrUSUMfEeI3MMm74Z73T9/kuz990yYVtfofjf3NuA42Jy3auFOpbjDyCSiIkTs1VIYw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1246,12 +1158,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", - "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1261,12 +1173,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", - "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1276,13 +1188,11 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.7.tgz", - "integrity": "sha512-mgDoQCRjrY3XK95UuV60tZlFCQGXEtMg8H+IsW72ldw1ih1jZhzYXbJvghmAEpg5UVhhnCeia1CkGttUvCkiMQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", + "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "regenerator-transform": "^0.15.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1291,13 +1201,27 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.7.tgz", - "integrity": "sha512-3OfyfRRqiGeOvIWSagcwUTVk2hXBsr/ww7bLn6TRTuXnexA+Udov2icFOxFX9abaj4l96ooYkcNN1qi2Zvqwng==", - "license": "MIT", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1307,12 +1231,11 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.7.tgz", - "integrity": "sha512-uBbxNwimHi5Bv3hUccmOFlUy3ATO6WagTApenHz9KzoIdn0XeACdB12ZJ4cjhuB2WSi80Ez2FWzJnarccriJeA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1322,13 +1245,12 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.7.tgz", - "integrity": "sha512-Mm6aeymI0PBh44xNIv/qvo8nmbkpZze1KvR8MkEqbIREDxoiWTi18Zr2jryfRMwDfVZF9foKh060fWgni44luw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1338,12 +1260,11 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.7.tgz", - "integrity": "sha512-ZFAeNkpGuLnAQ/NCsXJ6xik7Id+tHuS+NT+ue/2+rn/31zcdnupCdmunOizEaP0JsUmTFSTOPoQY7PkK2pttXw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1353,12 +1274,11 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.7.tgz", - "integrity": "sha512-SI274k0nUsFFmyQupiO7+wKATAmMFf8iFgq2O+vVFXZ0SV9lNfT1NGzBEhjquFmD8I9sqHLguH+gZVN3vww2AA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1368,12 +1288,11 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.7.tgz", - "integrity": "sha512-OmWmQtTHnO8RSUbL0NTdtpbZHeNTnm68Gj5pA4Y2blFNh+V4iZR68V1qL9cI37J21ZN7AaCnkfdHtLExQPf2uA==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1383,12 +1302,11 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.7.tgz", - "integrity": "sha512-BN87D7KpbdiABA+t3HbVqHzKWUDN3dymLaTnPFAMyc8lV+KN3+YzNhVRNdinaCPA4AUqx7ubXbQ9shRjYBl3SQ==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1398,13 +1316,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.7.tgz", - "integrity": "sha512-IWfR89zcEPQGB/iB408uGtSPlQd3Jpq11Im86vUgcmSTcoWAiQMCTOa2K2yNNqFJEBVICKhayctee65Ka8OB0w==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1414,13 +1331,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.7.tgz", - "integrity": "sha512-8JKfg/hiuA3qXnlLx8qtv5HWRbgyFx2hMMtpDDuU2rTckpKkGu4ycK5yYHwuEa16/quXfoxHBIApEsNyMWnt0g==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1430,13 +1346,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.7.tgz", - "integrity": "sha512-YRW8o9vzImwmh4Q3Rffd09bH5/hvY0pxg+1H1i0f7APoUeg12G7+HhLj9ZFNIrYkgBXhIijPJ+IXypN0hLTIbw==", - "license": "MIT", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1446,78 +1361,78 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.25.8", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.8.tgz", - "integrity": "sha512-58T2yulDHMN8YMUxiLq5YmWUnlDCyY1FsHM+v12VMx+1/FlrUj5tY50iDCpofFQEM8fMYOaY9YRvym2jcjn1Dg==", - "license": "MIT", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.2.tgz", + "integrity": "sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ==", "dependencies": { - "@babel/compat-data": "^7.25.8", - "@babel/helper-compilation-targets": "^7.25.7", - "@babel/helper-plugin-utils": "^7.25.7", - "@babel/helper-validator-option": "^7.25.7", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.7", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.7", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.7", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.7", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.7", + "@babel/compat-data": "^7.27.2", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.25.7", - "@babel/plugin-syntax-import-attributes": "^7.25.7", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.8", - "@babel/plugin-transform-async-to-generator": "^7.25.7", - "@babel/plugin-transform-block-scoped-functions": "^7.25.7", - "@babel/plugin-transform-block-scoping": "^7.25.7", - "@babel/plugin-transform-class-properties": "^7.25.7", - "@babel/plugin-transform-class-static-block": "^7.25.8", - "@babel/plugin-transform-classes": "^7.25.7", - "@babel/plugin-transform-computed-properties": "^7.25.7", - "@babel/plugin-transform-destructuring": "^7.25.7", - "@babel/plugin-transform-dotall-regex": "^7.25.7", - "@babel/plugin-transform-duplicate-keys": "^7.25.7", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.7", - "@babel/plugin-transform-dynamic-import": "^7.25.8", - "@babel/plugin-transform-exponentiation-operator": "^7.25.7", - "@babel/plugin-transform-export-namespace-from": "^7.25.8", - "@babel/plugin-transform-for-of": "^7.25.7", - "@babel/plugin-transform-function-name": "^7.25.7", - "@babel/plugin-transform-json-strings": "^7.25.8", - "@babel/plugin-transform-literals": "^7.25.7", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.8", - "@babel/plugin-transform-member-expression-literals": "^7.25.7", - "@babel/plugin-transform-modules-amd": "^7.25.7", - "@babel/plugin-transform-modules-commonjs": "^7.25.7", - "@babel/plugin-transform-modules-systemjs": "^7.25.7", - "@babel/plugin-transform-modules-umd": "^7.25.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.7", - "@babel/plugin-transform-new-target": "^7.25.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.8", - "@babel/plugin-transform-numeric-separator": "^7.25.8", - "@babel/plugin-transform-object-rest-spread": "^7.25.8", - "@babel/plugin-transform-object-super": "^7.25.7", - "@babel/plugin-transform-optional-catch-binding": "^7.25.8", - "@babel/plugin-transform-optional-chaining": "^7.25.8", - "@babel/plugin-transform-parameters": "^7.25.7", - "@babel/plugin-transform-private-methods": "^7.25.7", - "@babel/plugin-transform-private-property-in-object": "^7.25.8", - "@babel/plugin-transform-property-literals": "^7.25.7", - "@babel/plugin-transform-regenerator": "^7.25.7", - "@babel/plugin-transform-reserved-words": "^7.25.7", - "@babel/plugin-transform-shorthand-properties": "^7.25.7", - "@babel/plugin-transform-spread": "^7.25.7", - "@babel/plugin-transform-sticky-regex": "^7.25.7", - "@babel/plugin-transform-template-literals": "^7.25.7", - "@babel/plugin-transform-typeof-symbol": "^7.25.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.7", - "@babel/plugin-transform-unicode-property-regex": "^7.25.7", - "@babel/plugin-transform-unicode-regex": "^7.25.7", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.7", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.27.1", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.27.1", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.27.2", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.1", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.27.1", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.38.1", + "core-js-compat": "^3.40.0", "semver": "^6.3.1" }, "engines": { @@ -1531,7 +1446,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -1540,7 +1454,6 @@ "version": "0.1.6-no-external-plugins", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", @@ -1551,54 +1464,48 @@ } }, "node_modules/@babel/runtime": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz", - "integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "version": "7.25.7", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.25.7.tgz", - "integrity": "sha512-gMmIEhg35sXk9Te5qbGp3W9YKrvLt3HV658/d3odWrHSqT0JeG5OzsJWFHRLiOohRyjRsJc/x03DhJm3i8VJxg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.1.tgz", + "integrity": "sha512-909rVuj3phpjW6y0MCXAZ5iNeORePa6ldJvp2baWGcTjwqbBDDz6xoS5JHJ7lS88NlwLYj07ImL/8IUMtDZzTA==", "dev": true, - "license": "MIT", "dependencies": { - "core-js-pure": "^3.30.2", - "regenerator-runtime": "^0.14.0" + "core-js-pure": "^3.30.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1607,12 +1514,12 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1647,9 +1554,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.2.tgz", - "integrity": "sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.3.tgz", + "integrity": "sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==", "dev": true, "funding": [ { @@ -1670,9 +1577,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.8.tgz", - "integrity": "sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz", + "integrity": "sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==", "dev": true, "funding": [ { @@ -1686,7 +1593,7 @@ ], "dependencies": { "@csstools/color-helpers": "^5.0.2", - "@csstools/css-calc": "^2.1.2" + "@csstools/css-calc": "^2.1.3" }, "engines": { "node": ">=18" @@ -1740,13 +1647,12 @@ "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", - "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", - "license": "MIT" + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", "cpu": [ "ppc64" ], @@ -1760,9 +1666,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", "cpu": [ "arm" ], @@ -1776,9 +1682,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", "cpu": [ "arm64" ], @@ -1792,9 +1698,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", "cpu": [ "x64" ], @@ -1808,9 +1714,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", "cpu": [ "arm64" ], @@ -1824,9 +1730,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", "cpu": [ "x64" ], @@ -1840,9 +1746,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", "cpu": [ "arm64" ], @@ -1856,9 +1762,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", "cpu": [ "x64" ], @@ -1872,9 +1778,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", "cpu": [ "arm" ], @@ -1888,9 +1794,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", "cpu": [ "arm64" ], @@ -1904,9 +1810,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", "cpu": [ "ia32" ], @@ -1920,9 +1826,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", "cpu": [ "loong64" ], @@ -1936,9 +1842,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", "cpu": [ "mips64el" ], @@ -1952,9 +1858,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", "cpu": [ "ppc64" ], @@ -1968,9 +1874,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", "cpu": [ "riscv64" ], @@ -1984,9 +1890,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", "cpu": [ "s390x" ], @@ -2000,9 +1906,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", "cpu": [ "x64" ], @@ -2016,9 +1922,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", "cpu": [ "arm64" ], @@ -2032,9 +1938,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", "cpu": [ "x64" ], @@ -2048,9 +1954,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", "cpu": [ "arm64" ], @@ -2064,9 +1970,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", "cpu": [ "x64" ], @@ -2080,9 +1986,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", "cpu": [ "x64" ], @@ -2096,9 +2002,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", "cpu": [ "arm64" ], @@ -2112,9 +2018,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", "cpu": [ "ia32" ], @@ -2128,9 +2034,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", "cpu": [ "x64" ], @@ -2144,27 +2050,28 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, - "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, - "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -2174,7 +2081,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -2198,7 +2104,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2209,7 +2114,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -2225,7 +2129,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2238,7 +2141,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -2251,7 +2153,6 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -2262,7 +2163,6 @@ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, - "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -2277,7 +2177,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2288,7 +2187,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2301,7 +2199,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -2315,15 +2212,13 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" + "dev": true }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "ISC", "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -2341,7 +2236,6 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -2349,12 +2243,28 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -2370,7 +2280,6 @@ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -2380,7 +2289,6 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", "dev": true, - "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -2393,10 +2301,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "license": "MIT", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -2410,7 +2317,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -2419,7 +2325,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -2428,7 +2333,6 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2437,14 +2341,12 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "license": "MIT" + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2455,7 +2357,6 @@ "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2492,7 +2393,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -2501,7 +2401,6 @@ "version": "4.11.3", "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2525,7 +2424,6 @@ "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.61.tgz", "integrity": "sha512-rSzm+XKiNUjKegj8bzt5+pygZeckNLOr+IjykH8sYdVk7dE9y2ZuUSofiMV2bJk3qU+JHwexmw+q0RyNZB9ugg==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/utils": "^4.11.3", @@ -2552,7 +2450,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -2562,7 +2459,6 @@ "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", "@emotion/hash": "^0.8.0", @@ -2603,7 +2499,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -2612,7 +2507,6 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/utils": "^4.11.3", @@ -2641,7 +2535,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", - "license": "MIT", "peerDependencies": { "@types/react": "*" }, @@ -2655,7 +2548,6 @@ "version": "4.11.3", "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.4.4", "prop-types": "^15.7.2", @@ -2674,7 +2566,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2688,7 +2579,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "license": "MIT", "engines": { "node": ">= 8" } @@ -2698,7 +2588,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -2712,7 +2601,6 @@ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, - "license": "MIT", "optional": true, "engines": { "node": ">=14" @@ -2721,26 +2609,22 @@ "node_modules/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", - "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==", - "license": "MIT" + "integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==" }, "node_modules/@react-dnd/invariant": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz", - "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==", - "license": "MIT" + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" }, "node_modules/@react-dnd/shallowequal": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz", - "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==", - "license": "MIT" + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "node_modules/@react-icons/all-files": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", - "license": "MIT", "peerDependencies": { "react": "*" } @@ -2749,7 +2633,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.3.0.tgz", "integrity": "sha512-L+i+qIGuyWn7CIg7k1MteHGfttKPmxwZR5E7OsGikCL2LzYA0RERlaUY00Y3P3ZV2EYgrsYlBrGs6cJP5OKKqA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.6.3", "@redux-saga/deferred": "^1.2.1", @@ -2767,14 +2650,12 @@ "node_modules/@redux-saga/deferred": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.2.1.tgz", - "integrity": "sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g==", - "license": "MIT" + "integrity": "sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g==" }, "node_modules/@redux-saga/delay-p": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.2.1.tgz", "integrity": "sha512-MdiDxZdvb1m+Y0s4/hgdcAXntpUytr9g0hpcOO1XFVyyzkrDu3SKPgBFOtHn7lhu7n24ZKIAT1qtKyQjHqRd+w==", - "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.1.3" } @@ -2783,7 +2664,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.3.tgz", "integrity": "sha512-naXrkETG1jLRfVfhOx/ZdLj0EyAzHYbgJWkXbB3qFliPcHKiWbv/ULQryOAEKyjrhiclmr6AMdgsXFyx7/yE6Q==", - "license": "MIT", "dependencies": { "@redux-saga/symbols": "^1.1.3", "@redux-saga/types": "^1.2.1" @@ -2792,20 +2672,23 @@ "node_modules/@redux-saga/symbols": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.3.tgz", - "integrity": "sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg==", - "license": "MIT" + "integrity": "sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg==" }, "node_modules/@redux-saga/types": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz", - "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==", - "license": "MIT" + "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA==" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", + "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "dev": true }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz", - "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==", - "license": "MIT", + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", @@ -2826,18 +2709,20 @@ } }, "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "license": "MIT", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2846,7 +2731,6 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", - "license": "MIT", "dependencies": { "serialize-javascript": "^6.0.1", "smob": "^1.0.0", @@ -2865,14 +2749,13 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.2.tgz", - "integrity": "sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==", - "license": "MIT", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" + "picomatch": "^4.0.2" }, "engines": { "node": ">=14.0.0" @@ -2889,8 +2772,18 @@ "node_modules/@rollup/pluginutils/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "node_modules/@sindresorhus/is": { "version": "0.14.0", @@ -2904,7 +2797,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", - "license": "Apache-2.0", "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", @@ -2916,7 +2808,6 @@ "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" } @@ -2937,7 +2828,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, - "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", @@ -2978,7 +2868,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2991,15 +2880,13 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@testing-library/react": { "version": "12.1.5", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", @@ -3018,7 +2905,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@types/react": ">=16.9.0", @@ -3048,7 +2934,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3068,7 +2953,6 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "deep-equal": "^2.0.5" } @@ -3090,15 +2974,13 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "devOptional": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -3108,11 +2990,10 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "devOptional": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" } @@ -3122,33 +3003,29 @@ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "devOptional": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", "devOptional": true, - "license": "MIT", "dependencies": { "@babel/types": "^7.20.7" } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "license": "MIT" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" }, "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", - "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", - "license": "MIT", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -3158,15 +3035,13 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", "dev": true, - "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -3176,7 +3051,6 @@ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, - "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } @@ -3185,8 +3059,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/minimist": { "version": "1.2.5", @@ -3194,12 +3067,12 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "22.15.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", "devOptional": true, "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/normalize-package-data": { @@ -3208,16 +3081,14 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" }, "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "license": "MIT" + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" }, "node_modules/@types/react": { - "version": "17.0.83", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.83.tgz", - "integrity": "sha512-l0m4ArKJvmFtR4e8UmKrj1pB4tUgOhJITf+mADyF/p69Ts1YAR/E+G9XEM0mHXKVRa1dQNHseyyDNzeuAXfXQw==", - "license": "MIT", + "version": "17.0.86", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.86.tgz", + "integrity": "sha512-lPFuSjA85jecet6D4ZsPvCFuSrz6g2hkTSUw8MM0x5z2EndPV/itGnYQ39abjxd7F+cAcxLGtKQjnLn9cNUz3g==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3237,7 +3108,6 @@ "version": "7.1.34", "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", - "license": "MIT", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", @@ -3246,61 +3116,53 @@ } }, "node_modules/@types/react-test-renderer": { - "version": "18.3.0", - "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.0.tgz", - "integrity": "sha512-HW4MuEYxfDbOHQsVlY/XtOvNHftCVEPhJF2pQXXwcUiUF+Oyb0usgp48HSgpK5rt8m9KZb22yqOeZm+rrVG8gw==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", "dev": true, - "license": "MIT", "dependencies": { "@types/react": "*" } }, "node_modules/@types/react-transition-group": { - "version": "4.4.11", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", - "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", - "license": "MIT", - "dependencies": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "peerDependencies": { "@types/react": "*" } }, "node_modules/@types/react/node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "license": "MIT" + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==" }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "license": "MIT" + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true, - "license": "MIT" + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, "node_modules/@types/yargs": { "version": "15.0.19", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", "dev": true, - "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -3309,15 +3171,13 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.5.1", "@typescript-eslint/scope-manager": "6.21.0", @@ -3353,7 +3213,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3382,7 +3241,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.21.0", "@typescript-eslint/visitor-keys": "6.21.0" @@ -3400,7 +3258,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/typescript-estree": "6.21.0", "@typescript-eslint/utils": "6.21.0", @@ -3428,7 +3285,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "dev": true, - "license": "MIT", "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -3442,7 +3298,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "6.21.0", "@typescript-eslint/visitor-keys": "6.21.0", @@ -3471,7 +3326,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", @@ -3497,7 +3351,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "dev": true, - "license": "MIT", "dependencies": { "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" @@ -3511,23 +3364,23 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true, - "license": "ISC" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true }, "node_modules/@vitejs/plugin-react": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", - "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", + "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", "dev": true, "dependencies": { - "@babel/core": "^7.26.0", + "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "react-refresh": "^0.17.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3537,9 +3390,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.8.tgz", - "integrity": "sha512-y7SAKsQirsEJ2F8bulBck4DoluhI2EEgTimHd6EEUgJBGKy9tC25cpywh1MH4FvDGoG2Unt7+asVd1kj4qOSAw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz", + "integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -3551,7 +3404,7 @@ "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.0", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -3559,8 +3412,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.8", - "vitest": "3.0.8" + "@vitest/browser": "3.1.4", + "vitest": "3.1.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3569,13 +3422,13 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.8.tgz", - "integrity": "sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", + "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", "dev": true, "dependencies": { - "@vitest/spy": "3.0.8", - "@vitest/utils": "3.0.8", + "@vitest/spy": "3.1.4", + "@vitest/utils": "3.1.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -3584,12 +3437,12 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.8.tgz", - "integrity": "sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", + "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", "dev": true, "dependencies": { - "@vitest/spy": "3.0.8", + "@vitest/spy": "3.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -3610,9 +3463,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz", - "integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", + "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", "dev": true, "dependencies": { "tinyrainbow": "^2.0.0" @@ -3622,12 +3475,12 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.8.tgz", - "integrity": "sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", + "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", "dev": true, "dependencies": { - "@vitest/utils": "3.0.8", + "@vitest/utils": "3.1.4", "pathe": "^2.0.3" }, "funding": { @@ -3635,12 +3488,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.8.tgz", - "integrity": "sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", + "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", "dev": true, "dependencies": { - "@vitest/pretty-format": "3.0.8", + "@vitest/pretty-format": "3.1.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -3649,9 +3502,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.8.tgz", - "integrity": "sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", + "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", "dev": true, "dependencies": { "tinyspy": "^3.0.2" @@ -3661,12 +3514,12 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.8.tgz", - "integrity": "sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", "dev": true, "dependencies": { - "@vitest/pretty-format": "3.0.8", + "@vitest/pretty-format": "3.1.4", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -3675,10 +3528,9 @@ } }, "node_modules/acorn": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz", - "integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==", - "license": "MIT", + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "bin": { "acorn": "bin/acorn" }, @@ -3691,7 +3543,6 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -3710,7 +3561,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3730,24 +3580,6 @@ "string-width": "^4.1.0" } }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3777,7 +3609,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", "engines": { "node": ">=8" } @@ -3786,7 +3617,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3813,15 +3643,13 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" + "dev": true }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "license": "Apache-2.0", "dependencies": { "dequal": "^2.0.3" } @@ -3846,7 +3674,6 @@ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -3867,7 +3694,6 @@ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3877,7 +3703,6 @@ "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -3894,16 +3719,15 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -3935,7 +3759,6 @@ "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -3988,14 +3811,12 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, "node_modules/async-function": { "version": "1.0.0", @@ -4005,26 +3826,18 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", - "license": "ISC", "engines": { "node": ">= 4.0.0" } }, "node_modules/attr-accept": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.4.tgz", - "integrity": "sha512-2pA6xFIbdTUDCAwjN8nQwI+842VwzbDUXO2IYlpPXQIORgKnavorcr4Ce3rwh+zsNg9zK7QPsdvDj3Lum4WX4w==", - "license": "MIT", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", "engines": { "node": ">=4" } @@ -4033,7 +3846,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", - "license": "MIT", "dependencies": { "remove-accents": "^0.4.2" } @@ -4042,7 +3854,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" }, @@ -4054,11 +3865,10 @@ } }, "node_modules/axe-core": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.1.tgz", - "integrity": "sha512-qPC9o+kD8Tir0lzNGLeghbOrWMr3ZJpaRlCIb6Uobt/7N4FiEDvqUMnxzCHRHmg8vOg14kr5gVNyScRmbMaJ9g==", + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", "dev": true, - "license": "MPL-2.0", "engines": { "node": ">=4" } @@ -4068,19 +3878,17 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.11", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", - "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", - "license": "MIT", + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", + "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", "dependencies": { "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.2", + "@babel/helper-define-polyfill-provider": "^0.6.4", "semver": "^6.3.1" }, "peerDependencies": { @@ -4091,31 +3899,28 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", - "license": "MIT", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", - "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", - "license": "MIT", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", + "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2" + "@babel/helper-define-polyfill-provider": "^0.6.4" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -4125,23 +3930,15 @@ "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "license": "MIT", "dependencies": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" } }, - "node_modules/babel-runtime/node_modules/regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", - "license": "MIT" - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -4186,8 +3983,7 @@ "node_modules/blueimp-md5": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz", - "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==", - "license": "MIT" + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==" }, "node_modules/boxen": { "version": "4.2.0", @@ -4222,24 +4018,6 @@ "node": ">=8" } }, - "node_modules/boxen/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/boxen/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/boxen/node_modules/type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -4252,7 +4030,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -4261,7 +4038,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", "dependencies": { "fill-range": "^7.1.1" }, @@ -4270,9 +4046,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.0.tgz", - "integrity": "sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==", + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", "funding": [ { "type": "opencollective", @@ -4287,12 +4063,11 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001663", - "electron-to-chromium": "^1.5.28", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -4327,8 +4102,7 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/cac": { "version": "6.7.14", @@ -4440,7 +4214,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -4470,9 +4243,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001669", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", - "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", "funding": [ { "type": "opencollective", @@ -4486,8 +4259,7 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, "node_modules/chai": { "version": "5.2.0", @@ -4509,7 +4281,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -4577,8 +4348,7 @@ "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" }, "node_modules/cli-boxes": { "version": "2.2.1", @@ -4644,7 +4414,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", "engines": { "node": ">=6" } @@ -4653,7 +4422,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -4664,32 +4432,17 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "license": "MIT", "engines": { "node": ">=4.0.0" } @@ -4697,14 +4450,12 @@ "node_modules/compute-scroll-into-view": { "version": "1.0.20", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz", - "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==", - "license": "MIT" + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==" }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/configstore": { "version": "5.0.1", @@ -4748,7 +4499,6 @@ "version": "6.9.3", "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", - "license": "MIT", "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -4768,24 +4518,21 @@ "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/core-js": { "version": "2.6.12", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "hasInstallScript": true, - "license": "MIT" + "hasInstallScript": true }, "node_modules/core-js-compat": { - "version": "3.38.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", - "integrity": "sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==", - "license": "MIT", + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", + "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", "dependencies": { - "browserslist": "^4.23.3" + "browserslist": "^4.24.4" }, "funding": { "type": "opencollective", @@ -4793,12 +4540,11 @@ } }, "node_modules/core-js-pure": { - "version": "3.38.1", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.38.1.tgz", - "integrity": "sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==", + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.42.0.tgz", + "integrity": "sha512-007bM04u91fF4kMgwom2I5cQxAFIy8jVulgr9eozILl/SZE53QOqnW/+vviC+wQWLv+AunBG+8Q0TLoeSsSxRQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" @@ -4822,7 +4568,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", - "license": "MIT", "engines": { "node": ">=8" } @@ -4830,14 +4575,12 @@ "node_modules/css-mediaquery": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", - "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", - "license": "BSD" + "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" }, "node_modules/css-vendor": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.3", "is-in-browser": "^1.0.2" @@ -4847,16 +4590,15 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/cssstyle": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz", - "integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.3.1.tgz", + "integrity": "sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==", "dev": true, "dependencies": { - "@asamuzakjp/css-color": "^2.8.2", + "@asamuzakjp/css-color": "^3.1.2", "rrweb-cssom": "^0.8.0" }, "engines": { @@ -4866,15 +4608,13 @@ "node_modules/csstype": { "version": "2.6.21", "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz", - "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==", - "license": "MIT" + "integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" + "dev": true }, "node_modules/data-urls": { "version": "5.0.0", @@ -4949,13 +4689,12 @@ "node_modules/date-fns": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", - "license": "MIT" + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dependencies": { "ms": "^2.1.3" }, @@ -5009,7 +4748,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", - "license": "MIT", "engines": { "node": ">=0.10" } @@ -5039,7 +4777,6 @@ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dev": true, - "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.5", @@ -5071,8 +4808,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/deep-extend": { "version": "0.6.0", @@ -5086,14 +4822,12 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5118,7 +4852,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -5135,7 +4868,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -5148,21 +4880,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -5172,7 +4894,6 @@ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, - "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -5184,7 +4905,6 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", - "license": "MIT", "dependencies": { "@react-dnd/asap": "^4.0.0", "@react-dnd/invariant": "^2.0.0", @@ -5196,7 +4916,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -5208,20 +4927,17 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/dom-align": { "version": "1.12.4", "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", - "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", - "license": "MIT" + "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==" }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" @@ -5230,8 +4946,7 @@ "node_modules/dom-helpers/node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/dompurify": { "version": "2.5.8", @@ -5260,14 +4975,12 @@ "node_modules/downloadjs": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", - "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==", - "license": "MIT" + "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==" }, "node_modules/downshift": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", "integrity": "sha512-mbUO9ZFhMGtksIeVWRFFjNOPN237VsUqZSEYi0VS0Wj38XNLzpgOBTUcUjdjFeB8KVgmrcRa6GGFkTbACpG6FA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", "compute-scroll-into-view": "^1.0.9", @@ -5281,8 +4994,7 @@ "node_modules/downshift/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5306,14 +5018,12 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "license": "Apache-2.0", "dependencies": { "jake": "^10.8.5" }, @@ -5325,17 +5035,15 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.40", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.40.tgz", - "integrity": "sha512-LYm78o6if4zTasnYclgQzxEcgMoIcybWOhkATWepN95uwVVWV0/IW10v+2sIeHE+bIYWipLneTftVyQm45UY7g==", - "license": "ISC" + "version": "1.5.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", + "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/end-of-stream": { "version": "1.4.4", @@ -5346,11 +5054,10 @@ } }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -5367,26 +5074,26 @@ } }, "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "version": "1.23.10", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.10.tgz", + "integrity": "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw==", "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -5402,13 +5109,13 @@ "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", @@ -5421,7 +5128,7 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -5442,7 +5149,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", "engines": { "node": ">= 0.4" } @@ -5452,7 +5158,6 @@ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -5472,8 +5177,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/es-iterator-helpers": { "version": "1.2.1", @@ -5503,9 +5207,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true }, "node_modules/es-object-atoms": { @@ -5534,13 +5238,15 @@ } }, "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, - "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { @@ -5560,9 +5266,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", "dev": true, "hasInstallScript": true, "bin": { @@ -5572,38 +5278,37 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" } }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", "engines": { "node": ">=6" } @@ -5621,7 +5326,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -5635,7 +5339,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5687,14 +5390,17 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, "peerDependencies": { "eslint": ">=7.0.0" } @@ -5742,7 +5448,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5753,7 +5458,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5762,9 +5466,9 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.37.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", - "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "dependencies": { "array-includes": "^3.1.8", @@ -5777,7 +5481,7 @@ "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.8", + "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", @@ -5806,9 +5510,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.19", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.19.tgz", - "integrity": "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==", + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", "dev": true, "peerDependencies": { "eslint": ">=8.40" @@ -5819,7 +5523,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5830,7 +5533,6 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, - "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -5843,7 +5545,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5856,7 +5557,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } @@ -5866,7 +5566,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -5883,7 +5582,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -5896,7 +5594,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5907,7 +5604,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -5923,7 +5619,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5936,7 +5631,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -5949,7 +5643,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -5967,7 +5660,6 @@ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -5980,7 +5672,6 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -5993,7 +5684,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -6011,7 +5701,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } @@ -6019,19 +5708,17 @@ "node_modules/eventemitter3": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==", - "license": "MIT" + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" }, "node_modules/exenv": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", - "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==", - "license": "BSD-3-Clause" + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" }, "node_modules/expect-type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", - "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", "dev": true, "engines": { "node": ">=12.0.0" @@ -6064,21 +5751,19 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -6089,7 +5774,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -6100,28 +5784,34 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "license": "MIT" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/fast-uri": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", - "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", - "license": "BSD-3-Clause" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", "dev": true, - "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -6153,7 +5843,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -6165,7 +5854,6 @@ "version": "0.1.19", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz", "integrity": "sha512-kCWw3+Aai8Uox+5tHCNgMFaUdgidxvMnLWO6fM5sZ0hA2wlHP5/DHGF0ECe84BiB95qdJbKNEJhWKVDvMN+JDQ==", - "license": "MIT", "dependencies": { "tslib": "^2.0.1" }, @@ -6177,7 +5865,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "license": "Apache-2.0", "dependencies": { "minimatch": "^5.0.1" } @@ -6186,7 +5873,6 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6198,7 +5884,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -6210,7 +5895,6 @@ "version": "4.20.10", "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -6226,7 +5910,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", - "license": "MIT", "peerDependencies": { "final-form": "^4.20.8" } @@ -6236,7 +5919,6 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6253,7 +5935,6 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, - "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -6264,29 +5945,32 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true, - "license": "ISC" + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "license": "MIT", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -6296,26 +5980,22 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" - }, "engines": { - "node": ">= 6" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "license": "MIT", "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -6329,15 +6009,13 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "hasInstallScript": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -6350,7 +6028,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6378,7 +6055,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6387,7 +6063,6 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -6418,14 +6093,12 @@ "node_modules/get-node-dimensions": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz", - "integrity": "sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==", - "license": "MIT" + "integrity": "sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==" }, "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", - "license": "ISC" + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" }, "node_modules/get-proto": { "version": "1.0.1", @@ -6471,7 +6144,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -6492,7 +6164,6 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -6504,7 +6175,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -6514,7 +6184,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6540,7 +6209,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", "engines": { "node": ">=4" } @@ -6549,7 +6217,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "license": "MIT", "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -6566,7 +6233,6 @@ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, - "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -6617,20 +6283,18 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/happy-dom": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.0.tgz", - "integrity": "sha512-LN2BIuvdFZ8snmF6LtQB2vYBzRmgCx+uqlFX9JpKVRHQ44NODNnOchB4ZW8404djHhdbQgEHRAkXCZ0zGOyzew==", + "version": "17.4.7", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.7.tgz", + "integrity": "sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==", "dev": true, "dependencies": { "webidl-conversions": "^7.0.0", @@ -6649,10 +6313,12 @@ } }, "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "license": "MIT", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6661,7 +6327,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", "engines": { "node": ">=8" } @@ -6670,7 +6335,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" }, @@ -6707,7 +6371,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" }, @@ -6730,7 +6393,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -6742,7 +6404,6 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -6756,7 +6417,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", "dependencies": { "react-is": "^16.7.0" } @@ -6764,8 +6424,7 @@ "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hosted-git-info": { "version": "2.8.9", @@ -6788,13 +6447,12 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==" }, "node_modules/http-proxy-agent": { "version": "7.0.2", @@ -6825,8 +6483,7 @@ "node_modules/hyphenate-style-name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", - "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", - "license": "BSD-3-Clause" + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" }, "node_modules/iconv-lite": { "version": "0.6.3", @@ -6869,7 +6526,6 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } @@ -6878,15 +6534,13 @@ "version": "4.3.7", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "license": "MIT", "optional": true }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -6910,7 +6564,6 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -6919,7 +6572,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "license": "MIT", "engines": { "node": ">=8" } @@ -6937,7 +6589,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -6946,8 +6597,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.7", @@ -6977,24 +6627,6 @@ "node": ">=8.0.0" } }, - "node_modules/inquirer/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/inquirer/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7009,14 +6641,13 @@ } }, "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -7108,7 +6739,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7128,10 +6758,9 @@ } }, "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "license": "MIT", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dependencies": { "hasown": "^2.0.2" }, @@ -7177,7 +6806,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7200,7 +6828,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", "engines": { "node": ">=8" } @@ -7226,7 +6853,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -7237,8 +6863,7 @@ "node_modules/is-in-browser": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", - "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", - "license": "MIT" + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==" }, "node_modules/is-installed-globally": { "version": "0.3.2", @@ -7267,7 +6892,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7278,14 +6902,12 @@ "node_modules/is-mobile": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz", - "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==", - "license": "MIT" + "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==" }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "license": "MIT" + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" }, "node_modules/is-npm": { "version": "4.0.0", @@ -7299,7 +6921,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -7323,7 +6944,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7332,7 +6952,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "license": "MIT", "engines": { "node": ">=8" } @@ -7372,7 +6991,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7381,7 +6999,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7407,7 +7024,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", "engines": { "node": ">=8" }, @@ -7480,7 +7096,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -7503,13 +7118,12 @@ } }, "node_modules/is-weakset": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", - "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", - "license": "MIT", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -7526,22 +7140,19 @@ "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=8" } @@ -7551,7 +7162,6 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -7566,7 +7176,6 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", @@ -7581,7 +7190,6 @@ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -7612,7 +7220,6 @@ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -7627,7 +7234,6 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", - "license": "Apache-2.0", "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", @@ -7645,7 +7251,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -7655,7 +7260,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -7666,15 +7270,13 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -7683,15 +7285,14 @@ } }, "node_modules/jsdom": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz", - "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.1", + "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", @@ -7701,12 +7302,12 @@ "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^5.0.0", + "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.0", + "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, @@ -7732,10 +7333,9 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "license": "MIT", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "bin": { "jsesc": "bin/jsesc" }, @@ -7747,8 +7347,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -7758,28 +7357,24 @@ "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "license": "MIT", "bin": { "json5": "lib/cli.js" }, @@ -7791,7 +7386,6 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-2.5.2.tgz", "integrity": "sha512-4joNLCxxUAmS22GN3GA5os/MYFnq8oqXOKvoCymmcT0MPz/QPZ5eA+Fh5sIPxUji45RKq8DdQ1yoKq91p4E9VA==", - "license": "Apache-2.0", "bin": { "jsonexport": "bin/jsonexport.js" } @@ -7800,7 +7394,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -7812,7 +7405,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -7821,7 +7413,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "csstype": "^3.0.2", @@ -7837,7 +7428,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "hyphenate-style-name": "^1.0.3", @@ -7848,7 +7438,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "jss": "10.10.0" @@ -7858,7 +7447,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "jss": "10.10.0" @@ -7868,7 +7456,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "jss": "10.10.0", @@ -7879,7 +7466,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "jss": "10.10.0" @@ -7889,7 +7475,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "jss": "10.10.0", @@ -7900,7 +7485,6 @@ "version": "10.10.0", "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.3.1", "css-vendor": "^2.0.8", @@ -7910,15 +7494,13 @@ "node_modules/jss/node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, - "license": "MIT", "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -7933,7 +7515,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", "engines": { "node": ">=18" } @@ -7943,7 +7524,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, - "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -7960,15 +7540,13 @@ "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" + "dev": true }, "node_modules/language-tags": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, - "license": "MIT", "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -7991,7 +7569,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "license": "MIT", "engines": { "node": ">=6" } @@ -8001,7 +7578,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -8020,7 +7596,6 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -8034,39 +7609,33 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "license": "MIT" + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "node_modules/lodash.isequalwith": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", - "integrity": "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==", - "license": "MIT" + "integrity": "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ==" }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "license": "MIT" + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" }, "node_modules/lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", - "license": "MIT" + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -8087,7 +7656,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -8113,7 +7681,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", "dependencies": { "yallist": "^3.0.2" } @@ -8123,7 +7690,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "license": "MIT", "bin": { "lz-string": "bin/bin.js" } @@ -8142,7 +7708,6 @@ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", @@ -8154,7 +7719,6 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, - "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -8224,7 +7788,6 @@ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, - "license": "MIT", "engines": { "node": ">= 8" } @@ -8234,7 +7797,6 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -8243,27 +7805,6 @@ "node": ">=8.6" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -8284,7 +7825,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "license": "MIT", "engines": { "node": ">=4" } @@ -8294,7 +7834,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -8331,7 +7870,6 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, - "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } @@ -8339,8 +7877,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/mute-stream": { "version": "0.0.8", @@ -8348,9 +7885,9 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, "node_modules/nanoid": { - "version": "3.3.9", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz", - "integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -8369,14 +7906,12 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/navidrome-music-player": { "version": "4.25.1", "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.1.tgz", "integrity": "sha512-bHYr84ATUf/4+/PUoTpUSmpF4/igBx2UPhgnPqvda4FND+GJZtb1ikbMs1U+mhkNEUebe+2I29ob1zY7YZdtjg==", - "license": "MIT", "dependencies": { "@react-icons/all-files": "^4.1.0", "classnames": "^2.3.1", @@ -8397,7 +7932,6 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.6.0.tgz", "integrity": "sha512-ZZFkaYzIfGfBvSM6QhA9dM8EEaUJOVewzGSRcXWbJELXDj0lajAtKaENCYxvF5yE+TgHg6NQb0CmgYMsMdcNJQ==", - "license": "BSD-2-Clause", "dependencies": { "hasown": "^2.0.2", "object.entries": "^1.1.8", @@ -8408,10 +7942,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "license": "MIT" + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" }, "node_modules/normalize-package-data": { "version": "2.5.0", @@ -8425,17 +7958,20 @@ } }, "node_modules/normalize-package-data/node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8465,16 +8001,15 @@ } }, "node_modules/nwsapi": { - "version": "2.2.18", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.18.tgz", - "integrity": "sha512-p1TRH/edngVEHVbwqWnxUViEmq5znDvyB+Sik5cmuLpGOIfDf/39zLiq3swPF8Vakqn+gvNiOQAZu8djYlQILA==", + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", "dev": true }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8495,7 +8030,6 @@ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" @@ -8511,7 +8045,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", "engines": { "node": ">= 0.4" } @@ -8536,14 +8069,14 @@ } }, "node_modules/object.entries": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", - "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", - "license": "MIT", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -8554,7 +8087,6 @@ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -8590,7 +8122,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", "dependencies": { "wrappy": "1" } @@ -8614,7 +8145,6 @@ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -8686,7 +8216,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -8702,7 +8231,6 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -8739,8 +8267,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" + "dev": true }, "node_modules/package-json/node_modules/semver": { "version": "6.3.1", @@ -8755,7 +8282,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, - "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -8781,12 +8307,12 @@ } }, "node_modules/parse5": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", - "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "dependencies": { - "entities": "^4.5.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -8796,7 +8322,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", "engines": { "node": ">=8" } @@ -8805,7 +8330,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8815,7 +8339,6 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -8823,15 +8346,13 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -8847,14 +8368,12 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "dev": true }, "node_modules/path-to-regexp": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", - "license": "MIT", "dependencies": { "isarray": "0.0.1" } @@ -8864,7 +8383,6 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -8887,14 +8405,12 @@ "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", "engines": { "node": ">=8.6" }, @@ -8905,14 +8421,12 @@ "node_modules/popper.js": { "version": "1.16.1-lts", "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", - "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", - "license": "MIT" + "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==" }, "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "license": "MIT", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "engines": { "node": ">= 0.4" } @@ -8950,7 +8464,6 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -8983,7 +8496,6 @@ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", "dev": true, - "license": "MIT", "engines": { "node": "^14.13.1 || >=16.0.0" }, @@ -8996,7 +8508,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -9011,7 +8522,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, @@ -9023,7 +8533,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9033,8 +8542,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/pump": { "version": "3.0.2", @@ -9049,7 +8557,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -9069,7 +8576,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", - "license": "MIT", "dependencies": { "decode-uri-component": "^0.2.0", "object-assign": "^4.1.0", @@ -9097,8 +8603,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] }, "node_modules/quick-lru": { "version": "4.0.1", @@ -9112,7 +8617,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", - "license": "MIT", "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9140,8 +8644,7 @@ "node_modules/ra-core/node_modules/classnames": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", - "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", - "license": "MIT" + "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==" }, "node_modules/ra-core/node_modules/inflection": { "version": "1.13.4", @@ -9155,7 +8658,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-data-json-server/-/ra-data-json-server-3.19.12.tgz", "integrity": "sha512-SEa0ueZd9LUG6iuPnHd+MHWf7BTgLKjx3Eky16VvTsqf6ueHkMU8AZiH1pHzrdxV6ku5VL34MCYWVSIbm2iDnw==", - "license": "MIT", "dependencies": { "query-string": "^5.1.1", "ra-core": "^3.19.12" @@ -9165,7 +8667,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.19.12.tgz", "integrity": "sha512-7VkNybY+RYVL5aDf8MdefYpRMkaELOjSXx7rrRY7PzVwmQzVe5ESoKBcH4Cob2M8a52pAlXY32dwmA3dZ91l/Q==", - "license": "MIT", "dependencies": { "node-polyglot": "^2.2.2", "ra-core": "^3.19.12" @@ -9175,7 +8676,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.19.12.tgz", "integrity": "sha512-aYY0ma74eXLuflPT9iXEQtVEDZxebw1NiQZ5pPGiBCpsq+hoiDWuzerLU13OdBHbySD5FHLuk89SkyAdfMtUaQ==", - "license": "MIT", "dependencies": { "ra-core": "^3.19.12" } @@ -9185,7 +8685,6 @@ "resolved": "https://registry.npmjs.org/ra-test/-/ra-test-3.19.12.tgz", "integrity": "sha512-SX6oi+VPADIeQeQlGWUVj2kgEYgLbizpzYMq+oacCmnAqvHezwnQ2MXrLDRK6C56YIl+t8DyY/ipYBiRPZnHbA==", "dev": true, - "license": "MIT", "dependencies": { "@testing-library/react": "^11.2.3", "classnames": "~2.3.1", @@ -9206,7 +8705,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9226,7 +8724,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz", "integrity": "sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^7.28.1" @@ -9243,15 +8740,13 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz", "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/ra-test/node_modules/aria-query": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.10.2", "@babel/runtime-corejs3": "^7.10.2" @@ -9264,15 +8759,13 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.3.tgz", "integrity": "sha512-1inzZmicIFcmUya7PGtUQeXtcF7zZpPnxtQoYOrz0uiOBGlLFa4ik4361seYL2JCcRDIyfdFHiwQolESFlw+Og==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/ra-test/node_modules/pretty-format": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", "dev": true, - "license": "MIT", "dependencies": { "@jest/types": "^26.6.2", "ansi-regex": "^5.0.0", @@ -9287,7 +8780,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-ui-materialui/-/ra-ui-materialui-3.19.12.tgz", "integrity": "sha512-8Zz88r5yprmUxOw9/F0A/kjjVmFMb2n+sjpel8fuOWtS6y++JWonDsvTwo4yIuSF9mC0fht3f/hd2KEHQdmj6Q==", - "license": "MIT", "dependencies": { "autosuggest-highlight": "^3.1.1", "classnames": "~2.2.5", @@ -9323,8 +8815,7 @@ "node_modules/ra-ui-materialui/node_modules/classnames": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", - "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", - "license": "MIT" + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" }, "node_modules/ra-ui-materialui/node_modules/inflection": { "version": "1.13.4", @@ -9338,7 +8829,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", "dependencies": { "safe-buffer": "^5.1.0" } @@ -9361,7 +8851,6 @@ "version": "4.0.15", "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", @@ -9375,14 +8864,13 @@ } }, "node_modules/rc-motion": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.3.tgz", - "integrity": "sha512-rkW47ABVkic7WEB0EKJqzySpvDqwl60/tdkY7hWP7dYnh5pm0SzJpo54oW3TDUGXV5wfxXFmMkxrzRRbotQ0+w==", - "license": "MIT", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", - "rc-util": "^5.43.0" + "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", @@ -9393,7 +8881,6 @@ "version": "9.7.5", "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.7.5.tgz", "integrity": "sha512-LV/MWcXFjco1epPbdw1JlLXlTgmWpB9/Y/P2yinf8Pg3wElHxA9uajN21lJiWtZjf5SCUekfSP6QMJfDo4t1hg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", @@ -9413,7 +8900,6 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-3.2.2.tgz", "integrity": "sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", @@ -9428,7 +8914,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.3.1.tgz", "integrity": "sha512-e6H0dMD38EPaSPD2XC8dRfct27VvT2TkPdoBSuNl3RRZ5tspiY/c5xYEmGC0IrABvMBgque4Mr2SMZuliCvoiQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.11.2", "classnames": "^2.3.1", @@ -9443,7 +8928,6 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "classnames": "^2.2.6", @@ -9460,10 +8944,9 @@ } }, "node_modules/rc-util": { - "version": "5.43.0", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.43.0.tgz", - "integrity": "sha512-AzC7KKOXFqAdIBqdGWepL9Xn7cm3vnAmjlHqUnoQaTMZYhM4VlXGLkkHHxj/BZ7Td0+SOPKB4RGPboBVKT9htw==", - "license": "MIT", + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" @@ -9476,8 +8959,7 @@ "node_modules/rc-util/node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "node_modules/rc/node_modules/strip-json-comments": { "version": "2.0.1", @@ -9491,7 +8973,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", - "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9504,7 +8985,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.19.12.tgz", "integrity": "sha512-LanWS3Yjie7n5GZI8v7oP73DSvQyCeZD0dpkC65IC0+UOhkInxa1zedJc8CyD3+ZwlgVC+CGqi6jQ1fo73Cdqw==", - "license": "MIT", "dependencies": { "@material-ui/core": "^4.12.1", "@material-ui/icons": "^4.11.2", @@ -9533,7 +9013,6 @@ "version": "14.0.5", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", - "license": "MIT", "dependencies": { "@react-dnd/invariant": "^2.0.0", "@react-dnd/shallowequal": "^2.0.0", @@ -9563,7 +9042,6 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", - "license": "MIT", "dependencies": { "dnd-core": "14.0.1" } @@ -9572,7 +9050,6 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -9586,7 +9063,6 @@ "version": "0.1.9", "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.9.tgz", "integrity": "sha512-/OsYevKtCUlw4FhJIfZPH7INHEmyl89sSC5COzonHW5Z2c8rHg4DNYFnUxOyqH+65o7sHweL13oaf6wr7dFvPA==", - "license": "MIT", "dependencies": { "babel-runtime": "^6.26.0", "prop-types": "^15.5.8" @@ -9596,7 +9072,6 @@ "version": "4.4.6", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", - "license": "MIT", "dependencies": { "clsx": "^1.1.1", "prop-types": "^15.8.1" @@ -9610,7 +9085,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", "engines": { "node": ">=6" } @@ -9619,7 +9093,6 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-10.2.2.tgz", "integrity": "sha512-U5EKckXVt6IrEyhMMsgmHQiWTGLudhajPPG77KFSvgsMqNEHSyGpqWvOMc5+DhEah/vH4E1n+J5weBNLd5VtyA==", - "license": "MIT", "dependencies": { "attr-accept": "^2.0.0", "file-selector": "^0.1.12", @@ -9637,7 +9110,6 @@ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, @@ -9653,7 +9125,6 @@ "version": "6.5.9", "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -9670,7 +9141,6 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -9685,7 +9155,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", - "license": "Apache-2.0", "peerDependencies": { "prop-types": "^15.6.0", "react": "^15.6.2 || ^16.0 || ^17 || ^18" @@ -9695,7 +9164,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", - "license": "ISC", "dependencies": { "prop-types": "^15.6.1" }, @@ -9716,7 +9184,6 @@ "resolved": "https://registry.npmjs.org/react-image-lightbox/-/react-image-lightbox-5.1.4.tgz", "integrity": "sha512-kTiAODz091bgT7SlWNHab0LSMZAPJtlNWDGKv7pLlLY1krmf7FuG1zxE0wyPpeA8gPdwfr3cu6sPwZRqWsc3Eg==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", "dependencies": { "prop-types": "^15.7.2", "react-modal": "^3.11.1" @@ -9729,20 +9196,17 @@ "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, "node_modules/react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "license": "MIT" + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, "node_modules/react-measure": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-2.5.2.tgz", "integrity": "sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.2.0", "get-node-dimensions": "^1.2.1", @@ -9755,29 +9219,24 @@ } }, "node_modules/react-modal": { - "version": "3.16.1", - "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", - "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", - "license": "MIT", + "version": "3.16.3", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.3.tgz", + "integrity": "sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==", "dependencies": { "exenv": "^1.2.0", "prop-types": "^15.7.2", "react-lifecycles-compat": "^3.0.0", "warning": "^4.0.3" }, - "engines": { - "node": ">=8" - }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", - "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" + "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", + "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, "node_modules/react-redux": { "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -9799,11 +9258,10 @@ } }, "node_modules/react-refresh": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", - "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9812,7 +9270,6 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -9832,7 +9289,6 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -9849,14 +9305,12 @@ "node_modules/react-router/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -9990,7 +9444,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "license": "MIT", "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -10003,7 +9456,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.9.2" } @@ -10012,7 +9464,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.3.0.tgz", "integrity": "sha512-J9RvCeAZXSTAibFY0kGw6Iy4EdyDNW7k6Q+liwX+bsck7QVsU78zz8vpBRweEfANxnnlG/xGGeOvf6r8UXzNJQ==", - "license": "MIT", "dependencies": { "@redux-saga/core": "^1.3.0" } @@ -10041,14 +9492,12 @@ "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "license": "MIT" + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "license": "MIT", "dependencies": { "regenerate": "^1.4.2" }, @@ -10057,29 +9506,20 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.4" - } + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" }, "node_modules/regexp.prototype.flags": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", - "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", - "license": "MIT", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { @@ -10090,15 +9530,14 @@ } }, "node_modules/regexpu-core": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.1.1.tgz", - "integrity": "sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==", - "license": "MIT", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.0", "regjsgen": "^0.8.0", - "regjsparser": "^0.11.0", + "regjsparser": "^0.12.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.1.0" }, @@ -10131,14 +9570,12 @@ "node_modules/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "license": "MIT" + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==" }, "node_modules/regjsparser": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.1.tgz", - "integrity": "sha512-1DHODs4B8p/mQHU9kr+jv8+wIC9mtG4eBHxWxIq5mhjE3D5oORhCc6deRKzTjs9DcfRFmj9BHSDguZklqCGFWQ==", - "license": "BSD-2-Clause", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", "dependencies": { "jsesc": "~3.0.2" }, @@ -10146,17 +9583,26 @@ "regjsparser": "bin/parser" } }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/remove-accents": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", - "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==", - "license": "MIT" + "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==" }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10164,21 +9610,18 @@ "node_modules/reselect": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", - "integrity": "sha512-b/6tFZCmRhtBMa4xGqiiRp9jh9Aqi2A687Lo265cN0/QohJQEBPiQ52f4QB6i0eF3yp3hmLL21LSGBcML2dlxA==", - "license": "MIT" + "integrity": "sha512-b/6tFZCmRhtBMa4xGqiiRp9jh9Aqi2A687Lo265cN0/QohJQEBPiQ52f4QB6i0eF3yp3hmLL21LSGBcML2dlxA==" }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "license": "MIT" + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" }, "node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, - "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -10196,7 +9639,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, - "license": "MIT", "engines": { "node": ">=4" } @@ -10204,8 +9646,7 @@ "node_modules/resolve-pathname": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", - "license": "MIT" + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" }, "node_modules/responselike": { "version": "1.0.2", @@ -10227,17 +9668,11 @@ "node": ">=8" } }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, - "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -10249,7 +9684,6 @@ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, - "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -10262,13 +9696,12 @@ }, "node_modules/rollup": { "name": "@rollup/wasm-node", - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.24.0.tgz", - "integrity": "sha512-LL6oALR6fKG6GihtH0K0uWLAl19Q/QJst+oKJT1VWwFo4sPLA0/7JeZaSqrpFWq8OPloiKx/NDG4BWppFSX2vQ==", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.41.1.tgz", + "integrity": "sha512-70qfem+U3hAgwNgOlnUQiIdfKHLELUxsEWbFWg3aErPUvsyXYF1HALJBwoDgMUhRWyn+SqWVneDTnO/Kbey9hg==", "devOptional": true, - "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.7" }, "bin": { "rollup": "dist/bin/rollup" @@ -10314,7 +9747,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -10375,8 +9807,7 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] }, "node_modules/safe-push-apply": { "version": "1.0.0", @@ -10417,8 +9848,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/saxes": { "version": "6.0.0", @@ -10436,7 +9866,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", - "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -10446,15 +9875,13 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/seamless-immutable/-/seamless-immutable-7.1.4.tgz", "integrity": "sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==", - "license": "BSD-3-Clause", "optional": true }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -10485,7 +9912,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "license": "BSD-3-Clause", "dependencies": { "randombytes": "^2.1.0" } @@ -10494,7 +9920,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -10511,7 +9936,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -10538,15 +9962,13 @@ "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, - "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -10559,7 +9981,6 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -10639,24 +10060,15 @@ "dev": true }, "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -10664,20 +10076,17 @@ "node_modules/smob": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", - "license": "MIT" + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" }, "node_modules/sortablejs": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz", - "integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg==", - "license": "MIT" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==" }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "license": "BSD-3-Clause", "dependencies": { "whatwg-url": "^7.0.0" }, @@ -10690,7 +10099,6 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10699,7 +10107,6 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -10709,7 +10116,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -10718,7 +10124,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "license": "MIT", "dependencies": { "punycode": "^2.1.0" } @@ -10726,14 +10131,12 @@ "node_modules/source-map/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "license": "BSD-2-Clause" + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" }, "node_modules/source-map/node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "license": "MIT", "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -10744,8 +10147,7 @@ "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "license": "MIT" + "deprecated": "Please use @jridgewell/sourcemap-codec instead" }, "node_modules/spdx-correct": { "version": "3.2.0", @@ -10771,9 +10173,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.20", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", - "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==" + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==" }, "node_modules/stackback": { "version": "0.0.2", @@ -10782,19 +10184,19 @@ "dev": true }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "dev": true }, "node_modules/stop-iteration-iterator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", - "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, - "license": "MIT", "dependencies": { - "internal-slot": "^1.0.4" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -10804,7 +10206,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10818,21 +10219,16 @@ } }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -10841,7 +10237,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -10855,44 +10250,18 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" + "dev": true }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -10933,7 +10302,6 @@ "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, - "license": "MIT", "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -10980,7 +10348,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "license": "MIT", "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -10997,7 +10364,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", - "license": "BSD-2-Clause", "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", @@ -11011,7 +10377,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11025,7 +10390,6 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11037,7 +10401,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", - "license": "MIT", "engines": { "node": ">=10" } @@ -11046,7 +10409,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "license": "MIT", "dependencies": { "min-indent": "^1.0.0" }, @@ -11059,7 +10421,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -11071,7 +10432,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -11083,7 +10443,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -11101,7 +10460,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", - "license": "MIT", "engines": { "node": ">=8" } @@ -11110,7 +10468,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", - "license": "MIT", "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", @@ -11136,13 +10493,12 @@ } }, "node_modules/terser": { - "version": "5.36.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", - "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", - "license": "BSD-2-Clause", + "version": "5.39.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", + "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.14.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -11158,7 +10514,6 @@ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, - "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", @@ -11173,7 +10528,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, - "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -11194,7 +10548,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -11209,8 +10562,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/through": { "version": "2.3.8", @@ -11220,14 +10572,12 @@ "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, "node_modules/tiny-warning": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "license": "MIT" + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, "node_modules/tinybench": { "version": "2.9.0", @@ -11242,12 +10592,12 @@ "dev": true }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", "dev": true, "dependencies": { - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2" }, "engines": { @@ -11258,9 +10608,9 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dev": true, "peerDependencies": { "picomatch": "^3 || ^4" @@ -11311,21 +10661,21 @@ } }, "node_modules/tldts": { - "version": "6.1.83", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.83.tgz", - "integrity": "sha512-FHxxNJJ0WNsEBPHyC1oesQb3rRoxpuho/z2g3zIIAhw1WHJeQsUzK1jYK8TI1/iClaa4fS3Z2TCA9mtxXsENSg==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "dependencies": { - "tldts-core": "^6.1.83" + "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.83", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.83.tgz", - "integrity": "sha512-I2wb9OJc6rXyh9d4aInhSNWChNI+ra6qDnFEGEwe9OoA68lE4Temw29bOkf1Uvwt8VZS079t1BFZdXVBmmB4dw==", + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true }, "node_modules/tmp": { @@ -11351,7 +10701,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -11372,9 +10721,9 @@ } }, "node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "dependencies": { "punycode": "^2.3.1" @@ -11392,11 +10741,10 @@ } }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", "dev": true, - "license": "MIT", "engines": { "node": ">=16" }, @@ -11405,17 +10753,15 @@ } }, "node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", - "license": "0BSD" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -11427,7 +10773,6 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -11514,9 +10859,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -11530,7 +10875,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", - "license": "MIT", "dependencies": { "typescript-logic": "^0.0.0" } @@ -11538,14 +10882,12 @@ "node_modules/typescript-logic": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", - "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", - "license": "MIT" + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" }, "node_modules/typescript-tuple": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", - "license": "MIT", "dependencies": { "typescript-compare": "^0.0.2" } @@ -11568,16 +10910,15 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "devOptional": true }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "license": "MIT", "engines": { "node": ">=4" } @@ -11586,7 +10927,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -11599,7 +10939,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "license": "MIT", "engines": { "node": ">=4" } @@ -11608,7 +10947,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "license": "MIT", "engines": { "node": ">=4" } @@ -11617,7 +10955,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "license": "MIT", "dependencies": { "crypto-random-string": "^2.0.0" }, @@ -11629,7 +10966,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "license": "MIT", "engines": { "node": ">= 10.0.0" } @@ -11638,16 +10974,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", - "license": "MIT", "engines": { "node": ">=4", "yarn": "*" } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "funding": [ { "type": "opencollective", @@ -11662,10 +10997,9 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -11717,7 +11051,6 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -11762,18 +11095,20 @@ "node_modules/value-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", - "license": "MIT" + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, "node_modules/vite": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz", - "integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -11837,14 +11172,14 @@ } }, "node_modules/vite-node": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.8.tgz", - "integrity": "sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", + "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", "dev": true, "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", + "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, @@ -11859,9 +11194,9 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.1.tgz", - "integrity": "sha512-rkTbKFbd232WdiRJ9R3u+hZmf5SfQljX1b45NF6oLA6DSktEKpYllgTo1l2lkiZWMWV78pABJtFjNXfBef3/3Q==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", + "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==", "dev": true, "dependencies": { "debug": "^4.3.6", @@ -11888,31 +11223,58 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.8.tgz", - "integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", + "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", "dev": true, "dependencies": { - "@vitest/expect": "3.0.8", - "@vitest/mocker": "3.0.8", - "@vitest/pretty-format": "^3.0.8", - "@vitest/runner": "3.0.8", - "@vitest/snapshot": "3.0.8", - "@vitest/spy": "3.0.8", - "@vitest/utils": "3.0.8", + "@vitest/expect": "3.1.4", + "@vitest/mocker": "3.1.4", + "@vitest/pretty-format": "^3.1.4", + "@vitest/runner": "3.1.4", + "@vitest/snapshot": "3.1.4", + "@vitest/spy": "3.1.4", + "@vitest/utils": "3.1.4", "chai": "^5.2.0", "debug": "^4.4.0", - "expect-type": "^1.1.0", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.0", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.8", + "vite-node": "3.1.4", "why-is-node-running": "^2.3.0" }, "bin": { @@ -11928,8 +11290,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.8", - "@vitest/ui": "3.0.8", + "@vitest/browser": "3.1.4", + "@vitest/ui": "3.1.4", "happy-dom": "*", "jsdom": "*" }, @@ -11973,7 +11335,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", "dependencies": { "loose-envify": "^1.0.0" } @@ -11991,7 +11352,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=12" } @@ -12013,18 +11373,17 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" } }, "node_modules/whatwg-url": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", - "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "dependencies": { - "tr46": "^5.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { @@ -12036,7 +11395,6 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -12100,7 +11458,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "license": "MIT", "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -12115,14 +11472,15 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", - "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "for-each": "^0.3.3", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, @@ -12160,30 +11518,11 @@ "node": ">=8" } }, - "node_modules/widest-line/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/widest-line/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -12256,7 +11595,6 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", - "license": "MIT", "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", @@ -12273,7 +11611,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" @@ -12296,7 +11633,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "license": "MIT", "dependencies": { "@rollup/pluginutils": "^3.1.0", "magic-string": "^0.25.7" @@ -12309,7 +11645,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "license": "MIT", "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", @@ -12325,14 +11660,12 @@ "node_modules/workbox-build/node_modules/@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "license": "MIT" + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12347,20 +11680,17 @@ "node_modules/workbox-build/node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "license": "MIT" + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/workbox-build/node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" } @@ -12369,7 +11699,6 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "license": "MIT", "engines": { "node": ">=6" }, @@ -12381,7 +11710,6 @@ "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", - "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -12545,7 +11873,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -12564,7 +11891,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -12577,34 +11903,11 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -12617,7 +11920,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" }, @@ -12625,12 +11927,28 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "dev": true, - "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" }, @@ -12644,8 +11962,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "3.0.3", @@ -12658,15 +11975,10 @@ "typedarray-to-buffer": "^3.1.5" } }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "dev": true, "engines": { "node": ">=10.0.0" @@ -12710,8 +12022,7 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yargs-parser": { "version": "18.1.3", @@ -12730,7 +12041,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, diff --git a/ui/package.json b/ui/package.json index a20cf108e..b9c93316b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,8 +6,8 @@ "start": "vite", "build": "vite build", "serve": "vite preview", - "test": "vitest", - "test:ci": "vitest --watch=false", + "test:watch": "vitest", + "test": "vitest --watch=false", "test:coverage": "vitest run --coverage --watch=false", "type-check": "tsc --noEmit", "lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0", @@ -16,7 +16,7 @@ "postinstall": "bin/update-workbox.sh" }, "dependencies": { - "@material-ui/core": "^4.11.4", + "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.11.3", "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.5", @@ -35,9 +35,9 @@ "react": "^17.0.2", "react-admin": "^3.19.12", "react-dnd": "^14.0.5", - "react-dnd-html5-backend": "^14.0.2", + "react-dnd-html5-backend": "^14.1.0", "react-dom": "^17.0.2", - "react-drag-listview": "^0.1.8", + "react-drag-listview": "^0.1.9", "react-ga": "^3.3.1", "react-hotkeys": "^2.0.0", "react-icons": "^5.5.0", @@ -46,7 +46,7 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.4", "redux": "^4.2.1", - "redux-saga": "^1.1.3", + "redux-saga": "^1.3.0", "uuid": "^11.1.0", "workbox-cli": "^7.3.0" }, @@ -55,27 +55,27 @@ "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.9", - "@types/react": "^17.0.83", + "@types/node": "^22.15.21", + "@types/react": "^17.0.86", "@types/react-dom": "^17.0.26", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^4.3.4", - "@vitest/coverage-v8": "^3.0.8", + "@vitejs/plugin-react": "^4.5.0", + "@vitest/coverage-v8": "^3.1.4", "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", + "eslint-config-prettier": "^10.1.5", "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "happy-dom": "^17.4.0", - "jsdom": "^26.0.0", + "eslint-plugin-react-refresh": "^0.4.20", + "happy-dom": "^17.4.7", + "jsdom": "^26.1.0", "prettier": "^3.5.3", "ra-test": "^3.19.12", - "typescript": "^5.8.2", - "vite": "^6.2.1", - "vite-plugin-pwa": "^0.21.1", - "vitest": "^3.0.8" + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-plugin-pwa": "^0.21.2", + "vitest": "^3.1.4" }, "overrides": { "vite": { diff --git a/ui/src/App.jsx b/ui/src/App.jsx index a3a34a5f3..8469ac27e 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -22,6 +22,7 @@ import { addToPlaylistDialogReducer, expandInfoDialogReducer, listenBrainzTokenDialogReducer, + saveQueueDialogReducer, playerReducer, albumViewReducer, activityReducer, @@ -62,6 +63,7 @@ const adminStore = createAdminStore({ downloadMenuDialog: downloadMenuDialogReducer, expandInfoDialog: expandInfoDialogReducer, listenBrainzTokenDialog: listenBrainzTokenDialogReducer, + saveQueueDialog: saveQueueDialogReducer, shareDialog: shareDialogReducer, activity: activityReducer, settings: settingsReducer, @@ -135,6 +137,7 @@ const Admin = (props) => { <Resource name="playlistTrack" />, <Resource name="keepalive" />, <Resource name="insights" />, + <Resource name="config" />, <Player />, ]} </RAAdmin> diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js index 8fac6076a..dea4d6203 100644 --- a/ui/src/actions/dialogs.js +++ b/ui/src/actions/dialogs.js @@ -8,6 +8,8 @@ export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN' export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE' export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN' export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE' +export const SAVE_QUEUE_OPEN = 'SAVE_QUEUE_OPEN' +export const SAVE_QUEUE_CLOSE = 'SAVE_QUEUE_CLOSE' export const DOWNLOAD_MENU_ALBUM = 'album' export const DOWNLOAD_MENU_ARTIST = 'artist' export const DOWNLOAD_MENU_PLAY = 'playlist' @@ -76,3 +78,11 @@ export const openListenBrainzTokenDialog = () => ({ export const closeListenBrainzTokenDialog = () => ({ type: LISTENBRAINZ_TOKEN_CLOSE, }) + +export const openSaveQueueDialog = () => ({ + type: SAVE_QUEUE_OPEN, +}) + +export const closeSaveQueueDialog = () => ({ + type: SAVE_QUEUE_CLOSE, +}) diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 7d89c5feb..995534550 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -1,6 +1,8 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' +export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ type, @@ -11,7 +13,17 @@ export const scanStatusUpdate = (data) => ({ data: data, }) +export const nowPlayingCountUpdate = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, }) + +export const streamReconnected = () => ({ + type: EVENT_STREAM_RECONNECTED, + data: {}, +}) diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index f796f3b9d..8213eb9d4 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -72,6 +72,10 @@ const useStyles = makeStyles( width: '15em', minWidth: '15em', }, + backgroundColor: 'transparent', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }, cover: { objectFit: 'contain', @@ -79,6 +83,11 @@ const useStyles = makeStyles( display: 'block', width: '100%', height: '100%', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, }, loveButton: { top: theme.spacing(-0.2), @@ -213,6 +222,8 @@ const AlbumDetails = (props) => { const [isLightboxOpen, setLightboxOpen] = useState(false) const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() + const [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) let notes = albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes @@ -236,23 +247,51 @@ const AlbumDetails = (props) => { }) }, [record]) + // Reset image state when album changes + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + const imageUrl = subsonic.getCoverArtUrl(record, 300) const fullImageUrl = subsonic.getCoverArtUrl(record) - const handleOpenLightbox = useCallback(() => setLightboxOpen(true), []) + const handleImageLoad = useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + return ( <Card className={classes.root}> <div className={classes.cardContents}> <div className={classes.coverParent}> <CardMedia + key={record.id} component={'img'} src={imageUrl} width="400" height="400" - className={classes.cover} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} title={record.name} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} /> </div> <div className={classes.details}> @@ -337,7 +376,7 @@ const AlbumDetails = (props) => { </Collapse> </div> )} - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( <Lightbox imagePadding={50} animationDuration={200} diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 475519fca..58732bbde 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -94,6 +94,10 @@ const useCoverStyles = makeStyles({ width: '100%', objectFit: 'contain', height: (props) => props.height, + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, }, }) @@ -113,6 +117,8 @@ const Cover = withContentRect('bounds')(({ // Force height to be the same as the width determined by the GridList // noinspection JSSuspiciousNameCombination const classes = useCoverStyles({ height: contentRect.bounds.width }) + const [imageLoading, setImageLoading] = React.useState(true) + const [imageError, setImageError] = React.useState(false) const [, dragAlbumRef] = useDrag( () => ({ type: DraggableTypes.ALBUM, @@ -121,13 +127,33 @@ const Cover = withContentRect('bounds')(({ }), [record], ) + + // Reset image state when record changes + React.useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = React.useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = React.useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + return ( <div ref={measureRef}> <div ref={dragAlbumRef}> <img + key={record.id} // Force re-render when record changes src={subsonic.getCoverArtUrl(record, 300, true)} alt={record.name} - className={classes.cover} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onLoad={handleImageLoad} + onError={handleImageError} /> </div> </div> diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 57fce81e0..40b927a89 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -10,6 +10,7 @@ import { ReferenceArrayInput, ReferenceInput, SearchInput, + usePermissions, useRefresh, useTranslate, useVersion, @@ -44,6 +45,8 @@ const useStyles = makeStyles({ const AlbumFilter = (props) => { const classes = useStyles() const translate = useTranslate() + const { permissions } = usePermissions() + const isAdmin = permissions === 'admin' return ( <Filter {...props} variant={'outlined'}> <SearchInput id="search" source="name" alwaysOn /> @@ -153,6 +156,7 @@ const AlbumFilter = (props) => { defaultValue={true} /> )} + {isAdmin && <NullableBooleanInput source="missing" />} </Filter> ) } diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.jsx index 1b706295a..c9e944999 100644 --- a/ui/src/album/AlbumShow.jsx +++ b/ui/src/album/AlbumShow.jsx @@ -4,11 +4,13 @@ import { ShowContextProvider, useShowContext, useShowController, + Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import AlbumSongs from './AlbumSongs' import AlbumDetails from './AlbumDetails' import AlbumActions from './AlbumActions' +import { useResourceRefresh, Title } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -25,9 +27,11 @@ const AlbumShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() + useResourceRefresh('album', 'song') return ( <> + {record && <RaTitle title={<Title subTitle={record.name} />} />} {record && <AlbumDetails {...context} />} {record && ( <ReferenceManyField diff --git a/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap index 4f8f83531..706e50a4f 100644 --- a/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap +++ b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap @@ -251,175 +251,3 @@ exports[`Details component > Mobile view > renders correctly with year range (st </span> </div> `; - -exports[`Details component > renders correctly in mobile view 1`] = ` -<div> - <span> - ♫ Mar 15, 2018 - </span> - · - <span> - ○ Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > renders correctly with all date fields 1`] = ` -<div> - <span> - resources.album.fields.originalDate Mar 15, 2018 - </span> - · - <span> - resources.album.fields.releaseDate Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-6" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > renders correctly with date 1`] = ` -<div> - <span> - May 1, 2020 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-2" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > renders correctly with date and originalDate 1`] = ` -<div> - <span> - resources.album.fields.originalDate Mar 15, 2018 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-4" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > renders correctly with just year range 1`] = ` -<div> - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-1" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > renders correctly with originalDate 1`] = ` -<div> - <span> - resources.album.fields.originalDate Mar 15, 2018 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-3" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > renders correctly with releaseDate 1`] = ` -<div> - <span> - resources.album.fields.releaseDate Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-5" - > - 100 KB - </span> - </span> -</div> -`; diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx new file mode 100644 index 000000000..c33ee892b --- /dev/null +++ b/ui/src/artist/ArtistActions.jsx @@ -0,0 +1,128 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +import { useMediaQuery } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { + Button, + TopToolbar, + sanitizeListRestProps, + useDataProvider, + useNotify, + useTranslate, +} from 'react-admin' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import { IoIosRadio } from 'react-icons/io' +import { playShuffle, playSimilar, playTopSongs } from './actions.js' + +const useStyles = makeStyles((theme) => ({ + toolbar: { + minHeight: 'auto', + padding: '0 !important', + background: 'transparent', + boxShadow: 'none', + '& .MuiToolbar-root': { + minHeight: 'auto', + padding: '0 !important', + background: 'transparent', + }, + }, + button: { + [theme.breakpoints.down('xs')]: { + minWidth: 'auto', + padding: '8px 12px', + fontSize: '0.75rem', + '& .MuiButton-startIcon': { + marginRight: '4px', + }, + }, + }, + radioIcon: { + [theme.breakpoints.down('xs')]: { + fontSize: '1.5rem', + }, + }, +})) + +const ArtistActions = ({ className, record, ...rest }) => { + const dispatch = useDispatch() + const translate = useTranslate() + const dataProvider = useDataProvider() + const notify = useNotify() + const classes = useStyles() + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs')) + + const handlePlay = React.useCallback(async () => { + try { + await playTopSongs(dispatch, notify, record.name) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error fetching top songs for artist:', e) + notify('ra.page.error', 'warning') + } + }, [dispatch, notify, record]) + + const handleShuffle = React.useCallback(async () => { + try { + await playShuffle(dataProvider, dispatch, record.id) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error fetching songs for shuffle:', e) + notify('ra.page.error', 'warning') + } + }, [dataProvider, dispatch, record, notify]) + + const handleRadio = React.useCallback(async () => { + try { + await playSimilar(dispatch, notify, record.id) + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error starting radio for artist:', e) + notify('ra.page.error', 'warning') + } + }, [dispatch, notify, record]) + + return ( + <TopToolbar + className={`${className} ${classes.toolbar}`} + {...sanitizeListRestProps(rest)} + > + <Button + onClick={handlePlay} + label={translate('resources.artist.actions.topSongs')} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + > + <PlayArrowIcon /> + </Button> + <Button + onClick={handleShuffle} + label={translate('resources.artist.actions.shuffle')} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + > + <ShuffleIcon /> + </Button> + <Button + onClick={handleRadio} + label={translate('resources.artist.actions.radio')} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + > + <IoIosRadio className={classes.radioIcon} /> + </Button> + </TopToolbar> + ) +} + +ArtistActions.propTypes = { + className: PropTypes.string, + record: PropTypes.object.isRequired, +} + +ArtistActions.defaultProps = { + className: '', +} + +export default ArtistActions diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx new file mode 100644 index 000000000..90be28409 --- /dev/null +++ b/ui/src/artist/ArtistActions.test.jsx @@ -0,0 +1,188 @@ +import React from 'react' +import { render, fireEvent, waitFor, screen } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import ArtistActions from './ArtistActions' +import subsonic from '../subsonic' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' + +const mockDispatch = vi.fn() +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) + +vi.mock('../subsonic', () => ({ + default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, +})) + +const mockNotify = vi.fn() +const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ getList: mockGetList }), + useTranslate: () => (x) => x, + } +}) + +describe('ArtistActions', () => { + const defaultRecord = { id: 'ar1', name: 'Artist' } + + const renderArtistActions = (record = defaultRecord) => { + const theme = createTheme() + return render( + <TestContext> + <ThemeProvider theme={theme}> + <ArtistActions record={record} /> + </ThemeProvider> + </TestContext>, + ) + } + + const clickActionButton = (actionKey) => { + fireEvent.click(screen.getByText(`resources.artist.actions.${actionKey}`)) + } + + beforeEach(() => { + vi.clearAllMocks() + // Mock console.error to suppress error logging in tests + vi.spyOn(console, 'error').mockImplementation(() => {}) + + subsonic.getSimilarSongs2.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + similarSongs2: { song: [{ id: 'rec1' }] }, + }, + }, + }) + subsonic.getTopSongs.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + topSongs: { song: [{ id: 'rec1' }] }, + }, + }, + }) + }) + + describe('Shuffle action', () => { + it('shuffles songs when clicked', async () => { + renderArtistActions() + clickActionButton('shuffle') + + await waitFor(() => + expect(mockGetList).toHaveBeenCalledWith('song', { + pagination: { page: 1, perPage: 500 }, + sort: { field: 'random', order: 'ASC' }, + filter: { album_artist_id: 'ar1', missing: false }, + }), + ) + expect(mockDispatch).toHaveBeenCalled() + }) + }) + + describe('Radio action', () => { + it('starts radio when clicked', async () => { + renderArtistActions() + clickActionButton('radio') + + await waitFor(() => + expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100), + ) + expect(mockDispatch).toHaveBeenCalled() + }) + }) + + describe('Play action', () => { + it('plays top songs when clicked', async () => { + renderArtistActions() + clickActionButton('topSongs') + + await waitFor(() => + expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), + ) + expect(mockDispatch).toHaveBeenCalled() + }) + + it('handles API rejection', async () => { + subsonic.getTopSongs.mockRejectedValue(new Error('Network error')) + + renderArtistActions() + clickActionButton('topSongs') + + await waitFor(() => + expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), + ) + expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('handles failed API response', async () => { + subsonic.getTopSongs.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'failed', + error: { code: 40, message: 'Wrong username or password' }, + }, + }, + }) + + renderArtistActions() + clickActionButton('topSongs') + + await waitFor(() => + expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), + ) + expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning') + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('handles empty song list', async () => { + subsonic.getTopSongs.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + topSongs: { song: [] }, + }, + }, + }) + + renderArtistActions() + clickActionButton('topSongs') + + await waitFor(() => + expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), + ) + expect(mockNotify).toHaveBeenCalledWith( + 'message.noTopSongsFound', + 'warning', + ) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('handles missing topSongs property', async () => { + subsonic.getTopSongs.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + // topSongs property is missing + }, + }, + }) + + renderArtistActions() + clickActionButton('topSongs') + + await waitFor(() => + expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), + ) + expect(mockNotify).toHaveBeenCalledWith( + 'message.noTopSongsFound', + 'warning', + ) + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index 9efa3925b..1b6d74560 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -6,8 +6,16 @@ import { ImLastfm2 } from 'react-icons/im' import MusicBrainz from '../icons/MusicBrainz' import { intersperse } from '../utils' import config from '../config' +import { makeStyles } from '@material-ui/core/styles' + +const useStyles = makeStyles({ + linkBar: { + minHeight: '1.875em', + }, +}) const ArtistExternalLinks = ({ artistInfo, record }) => { + const classes = useStyles() const translate = useTranslate() let linkButtons = [] const lastFMlink = artistInfo?.biography?.match( @@ -52,7 +60,7 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { <MusicBrainz className="musicbrainz-icon" />, ) - return <div>{intersperse(linkButtons, ' ')}</div> + return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div> } export default ArtistExternalLinks diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index f26cff217..7a14e9efe 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -11,6 +11,8 @@ import { SelectInput, TextField, useTranslate, + NullableBooleanInput, + usePermissions, } from 'react-admin' import { useMediaQuery, withWidth } from '@material-ui/core' import FavoriteIcon from '@material-ui/icons/Favorite' @@ -63,6 +65,8 @@ const useStyles = makeStyles({ const ArtistFilter = (props) => { const translate = useTranslate() + const { permissions } = usePermissions() + const isAdmin = permissions === 'admin' const rolesObj = en?.resources?.artist?.roles const roles = Object.keys(rolesObj).reduce((acc, role) => { acc.push({ @@ -85,6 +89,7 @@ const ArtistFilter = (props) => { defaultValue={true} /> )} + {isAdmin && <NullableBooleanInput source="missing" />} </Filter> ) } diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index b20fffeef..c6dc832c1 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -7,12 +7,46 @@ import { useShowContext, ReferenceManyField, Pagination, + Title as RaTitle, } from 'react-admin' import subsonic from '../subsonic' import AlbumGridView from '../album/AlbumGridView' import MobileArtistDetails from './MobileArtistDetails' import DesktopArtistDetails from './DesktopArtistDetails' -import { useAlbumsPerPage } from '../common/index.js' +import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js' +import ArtistActions from './ArtistActions' +import { makeStyles } from '@material-ui/core' + +const useStyles = makeStyles( + (theme) => ({ + actions: { + width: '100%', + justifyContent: 'flex-start', + display: 'flex', + paddingTop: '0.25em', + paddingBottom: '0.25em', + paddingLeft: '1em', + paddingRight: '1em', + flexWrap: 'wrap', + overflowX: 'auto', + [theme.breakpoints.down('xs')]: { + paddingLeft: '0.5em', + paddingRight: '0.5em', + gap: '0.5em', + justifyContent: 'space-around', + }, + }, + actionsContainer: { + paddingLeft: '.75rem', + [theme.breakpoints.down('xs')]: { + padding: '.5rem', + }, + }, + }), + { + name: 'NDArtistShow', + }, +) const ArtistDetails = (props) => { const record = useRecordContext(props) @@ -55,15 +89,17 @@ const ArtistShowLayout = (props) => { const record = useRecordContext() const { width } = props const [, perPageOptions] = useAlbumsPerPage(width) + const classes = useStyles() + useResourceRefresh('artist', 'album') const maxPerPage = 90 let perPage = 0 let pagination = null - const count = Math.max( - record?.stats?.['albumartist']?.albumCount || 0, - record?.stats?.['artist']?.albumCount ?? 0, - ) + // Use the main credit count instead of total count, as this is a precise measure + // of the number of albums where the artist is credited as an album artist OR + // artist + const count = record?.stats?.['maincredit']?.albumCount || 0 if (count > maxPerPage) { perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0] @@ -75,7 +111,13 @@ const ArtistShowLayout = (props) => { return ( <> + {record && <RaTitle title={<Title subTitle={record.name} />} />} {record && <ArtistDetails />} + {record && ( + <div className={classes.actionsContainer}> + <ArtistActions record={record} className={classes.actions} /> + </div> + )} {record && ( <ReferenceManyField {...showContext} diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 5d56bdef7..bff2c0906 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -29,6 +29,7 @@ const useStyles = makeStyles( float: 'left', wordBreak: 'break-word', cursor: 'pointer', + minHeight: '4.5em', }, content: { flex: '1 0 auto', @@ -38,11 +39,22 @@ const useStyles = makeStyles( height: '12rem', borderRadius: '6em', cursor: 'pointer', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + objectFit: 'cover', + }, + coverLoading: { + opacity: 0.5, }, artistImage: { maxHeight: '12rem', + minHeight: '12rem', + width: '12rem', + minWidth: '12rem', backgroundColor: 'inherit', display: 'flex', + alignItems: 'center', + justifyContent: 'center', boxShadow: 'none', }, artistDetail: { @@ -73,8 +85,31 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const classes = useStyles() const title = record.name const [isLightboxOpen, setLightboxOpen] = React.useState(false) + const [imageLoading, setImageLoading] = React.useState(false) + const [imageError, setImageError] = React.useState(false) + + // Reset image state when artist changes + React.useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = React.useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = React.useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = React.useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) - const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) const handleCloseLightbox = React.useCallback( () => setLightboxOpen(false), [], @@ -86,10 +121,17 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { <Card className={classes.artistImage}> {artistInfo && ( <CardMedia - className={classes.cover} - image={subsonic.getCoverArtUrl(record, 300)} + key={record.id} + component="img" + src={subsonic.getCoverArtUrl(record, 300)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} title={title} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} /> )} </Card> @@ -140,7 +182,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { )} </Typography> </div> - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( <Lightbox imagePadding={50} animationDuration={200} diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 4f1bac909..9d0450a66 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -50,6 +50,12 @@ const useStyles = makeStyles( width: 151, boxShadow: '0px 0px 6px 0px #565656', borderRadius: '5px', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + objectFit: 'cover', + }, + coverLoading: { + opacity: 0.5, }, artistImage: { marginLeft: '1em', @@ -81,8 +87,31 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { const classes = useStyles({ img, expanded }) const title = record.name const [isLightboxOpen, setLightboxOpen] = React.useState(false) + const [imageLoading, setImageLoading] = React.useState(false) + const [imageError, setImageError] = React.useState(false) + + // Reset image state when artist changes + React.useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = React.useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = React.useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = React.useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) - const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) const handleCloseLightbox = React.useCallback( () => setLightboxOpen(false), [], @@ -95,10 +124,17 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { <Card className={classes.artistImage}> {artistInfo && ( <CardMedia - className={classes.cover} - image={subsonic.getCoverArtUrl(record, 300)} + key={record.id} + component="img" + src={subsonic.getCoverArtUrl(record, 300)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} title={title} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} /> )} </Card> @@ -136,7 +172,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { </Typography> </Collapse> </div> - {isLightboxOpen && ( + {isLightboxOpen && !imageError && ( <Lightbox imagePadding={50} animationDuration={200} diff --git a/ui/src/artist/actions.js b/ui/src/artist/actions.js new file mode 100644 index 000000000..0ab648fa0 --- /dev/null +++ b/ui/src/artist/actions.js @@ -0,0 +1,68 @@ +import subsonic from '../subsonic/index.js' +import { playTracks } from '../actions/index.js' + +export const playTopSongs = async (dispatch, notify, artistName) => { + const res = await subsonic.getTopSongs(artistName, 100) + const data = res.json['subsonic-response'] + + if (data.status !== 'ok') { + throw new Error( + `Error fetching top songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, + ) + } + + const songs = data.topSongs?.song || [] + if (!songs.length) { + notify('message.noTopSongsFound', 'warning') + return + } + + const songData = {} + const ids = [] + songs.forEach((s) => { + songData[s.id] = s + ids.push(s.id) + }) + dispatch(playTracks(songData, ids)) +} + +export const playSimilar = async (dispatch, notify, id) => { + const res = await subsonic.getSimilarSongs2(id, 100) + const data = res.json['subsonic-response'] + + if (data.status !== 'ok') { + throw new Error( + `Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, + ) + } + + const songs = data.similarSongs2?.song || [] + if (!songs.length) { + notify('message.noSimilarSongsFound', 'warning') + return + } + + const songData = {} + const ids = [] + songs.forEach((s) => { + songData[s.id] = s + ids.push(s.id) + }) + dispatch(playTracks(songData, ids)) +} + +export const playShuffle = async (dataProvider, dispatch, id) => { + const res = await dataProvider.getList('song', { + pagination: { page: 1, perPage: 500 }, + sort: { field: 'random', order: 'ASC' }, + filter: { album_artist_id: id, missing: false }, + }) + + const data = {} + const ids = [] + res.data.forEach((s) => { + data[s.id] = s + ids.push(s.id) + }) + dispatch(playTracks(data, ids)) +} diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index aebd37170..093bb53fb 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -38,16 +38,14 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { const subtitle = song.tags?.['subtitle'] const title = song.title + (subtitle ? ` (${subtitle})` : '') + const linkTo = audioInfo.isRadio + ? `/radio/${audioInfo.trackId}/show` + : song.playlistId + ? `/playlist/${song.playlistId}/show` + : `/album/${song.albumId}/show` + return ( - <Link - to={ - audioInfo.isRadio - ? `/radio/${audioInfo.trackId}/show` - : `/album/${song.albumId}/show` - } - className={className} - ref={dragSongRef} - > + <Link to={linkTo} className={className} ref={dragSongRef}> <span> <span className={clsx(classes.songTitle, 'songTitle')}>{title}</span> {isDesktop && ( diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx new file mode 100644 index 000000000..7b297c07e --- /dev/null +++ b/ui/src/audioplayer/AudioTitle.test.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import AudioTitle from './AudioTitle' + +vi.mock('@material-ui/core', async () => { + const actual = await import('@material-ui/core') + return { + ...actual, + useMediaQuery: vi.fn(), + } +}) + +vi.mock('react-router-dom', () => ({ + // eslint-disable-next-line react/display-name + Link: React.forwardRef(({ to, children, ...props }, ref) => ( + <a href={to} ref={ref} {...props}> + {children} + </a> + )), +})) + +vi.mock('react-dnd', () => ({ + useDrag: vi.fn(() => [null, () => {}]), +})) + +describe('<AudioTitle />', () => { + const baseSong = { + id: 'song-1', + albumId: 'album-1', + playlistId: 'playlist-1', + title: 'Test Song', + artist: 'Artist', + album: 'Album', + year: '2020', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('links to playlist when playlistId is provided', () => { + const audioInfo = { trackId: 'track-1', song: baseSong } + render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />) + const link = screen.getByRole('link') + expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show') + }) + + it('falls back to album link when no playlistId', () => { + const audioInfo = { + trackId: 'track-1', + song: { ...baseSong, playlistId: undefined }, + } + render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />) + const link = screen.getByRole('link') + expect(link.getAttribute('href')).toBe('/album/album-1/show') + }) +}) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 1f57737d0..05ca6ddf7 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -214,7 +214,8 @@ const Player = () => { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - subsonic.nowPlaying(info.trackId) + const pos = startTime === null ? null : Math.floor(info.currentTime) + subsonic.nowPlaying(info.trackId, pos) } setPreload(false) if (config.gaTrackingId) { diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx index d3645f5b0..4812141ab 100644 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -1,32 +1,120 @@ import React, { useCallback } from 'react' +import { useDispatch } from 'react-redux' import { useGetOne } from 'react-admin' import { GlobalHotKeys } from 'react-hotkeys' +import IconButton from '@material-ui/core/IconButton' +import { useMediaQuery } from '@material-ui/core' +import { RiSaveLine } from 'react-icons/ri' import { LoveButton, useToggleLove } from '../common' +import { openSaveQueueDialog } from '../actions' import { keyMap } from '../hotkeys' +import { makeStyles } from '@material-ui/core/styles' -const Placeholder = () => <LoveButton disabled={true} resource={'song'} /> +const useStyles = makeStyles((theme) => ({ + toolbar: { + display: 'flex', + alignItems: 'center', + flexGrow: 1, + justifyContent: 'flex-end', + gap: '0.5rem', + listStyle: 'none', + padding: 0, + margin: 0, + }, + mobileListItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + listStyle: 'none', + padding: theme.spacing(0.5), + margin: 0, + height: 24, + }, + button: { + width: '2.5rem', + height: '2.5rem', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + }, + mobileButton: { + width: 24, + height: 24, + padding: 0, + margin: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '18px', + }, + mobileIcon: { + fontSize: '18px', + display: 'flex', + alignItems: 'center', + }, +})) -const Toolbar = ({ id }) => { - const { data, loading } = useGetOne('song', id) +const PlayerToolbar = ({ id, isRadio }) => { + const dispatch = useDispatch() + const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio }) const [toggleLove, toggling] = useToggleLove('song', data) + const isDesktop = useMediaQuery('(min-width:810px)') + const classes = useStyles() const handlers = { TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]), } + const handleSaveQueue = useCallback( + (e) => { + dispatch(openSaveQueueDialog()) + e.stopPropagation() + }, + [dispatch], + ) + + const buttonClass = isDesktop ? classes.button : classes.mobileButton + const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem + + const saveQueueButton = ( + <IconButton + size={isDesktop ? 'small' : undefined} + onClick={handleSaveQueue} + disabled={isRadio} + data-testid="save-queue-button" + className={buttonClass} + > + <RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} /> + </IconButton> + ) + + const loveButton = ( + <LoveButton + record={data} + resource={'song'} + size={isDesktop ? undefined : 'inherit'} + disabled={loading || toggling || !id || isRadio} + className={buttonClass} + /> + ) + return ( <> <GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges /> - <LoveButton - record={data} - resource={'song'} - disabled={loading || toggling} - /> + {isDesktop ? ( + <li className={`${listItemClass} item`}> + {saveQueueButton} + {loveButton} + </li> + ) : ( + <> + <li className={`${listItemClass} item`}>{saveQueueButton}</li> + <li className={`${listItemClass} item`}>{loveButton}</li> + </> + )} </> ) } -const PlayerToolbar = ({ id, isRadio }) => - id && !isRadio ? <Toolbar id={id} /> : <Placeholder /> - export default PlayerToolbar diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx new file mode 100644 index 000000000..d0368b0f0 --- /dev/null +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -0,0 +1,166 @@ +import React from 'react' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { useMediaQuery } from '@material-ui/core' +import { useGetOne } from 'react-admin' +import { useDispatch } from 'react-redux' +import { useToggleLove } from '../common' +import { openSaveQueueDialog } from '../actions' +import PlayerToolbar from './PlayerToolbar' + +// Mock dependencies +vi.mock('@material-ui/core', async () => { + const actual = await import('@material-ui/core') + return { + ...actual, + useMediaQuery: vi.fn(), + } +}) + +vi.mock('react-admin', () => ({ + useGetOne: vi.fn(), +})) + +vi.mock('react-redux', () => ({ + useDispatch: vi.fn(), +})) + +vi.mock('../common', () => ({ + LoveButton: ({ className, disabled }) => ( + <button data-testid="love-button" className={className} disabled={disabled}> + Love + </button> + ), + useToggleLove: vi.fn(), +})) + +vi.mock('../actions', () => ({ + openSaveQueueDialog: vi.fn(), +})) + +vi.mock('react-hotkeys', () => ({ + GlobalHotKeys: () => <div data-testid="global-hotkeys" />, +})) + +describe('<PlayerToolbar />', () => { + const mockToggleLove = vi.fn() + const mockDispatch = vi.fn() + const mockSongData = { id: 'song-1', name: 'Test Song', starred: false } + + beforeEach(() => { + vi.clearAllMocks() + useGetOne.mockReturnValue({ data: mockSongData, loading: false }) + useToggleLove.mockReturnValue([mockToggleLove, false]) + useDispatch.mockReturnValue(mockDispatch) + openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' }) + }) + + afterEach(cleanup) + + describe('Desktop layout', () => { + beforeEach(() => { + useMediaQuery.mockReturnValue(true) // isDesktop = true + }) + + it('renders desktop toolbar with both buttons', () => { + render(<PlayerToolbar id="song-1" />) + + // Both buttons should be in a single list item + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(1) + + // Verify both buttons are rendered + expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() + expect(screen.getByTestId('love-button')).toBeInTheDocument() + + // Verify desktop classes are applied + expect(listItems[0].className).toContain('toolbar') + }) + + it('disables save queue button when isRadio is true', () => { + render(<PlayerToolbar id="song-1" isRadio={true} />) + + const saveQueueButton = screen.getByTestId('save-queue-button') + expect(saveQueueButton).toBeDisabled() + }) + + it('disables love button when conditions are met', () => { + useGetOne.mockReturnValue({ data: mockSongData, loading: true }) + + render(<PlayerToolbar id="song-1" />) + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + + it('opens save queue dialog when save button is clicked', () => { + render(<PlayerToolbar id="song-1" />) + + const saveQueueButton = screen.getByTestId('save-queue-button') + fireEvent.click(saveQueueButton) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'OPEN_SAVE_QUEUE_DIALOG', + }) + }) + }) + + describe('Mobile layout', () => { + beforeEach(() => { + useMediaQuery.mockReturnValue(false) // isDesktop = false + }) + + it('renders mobile toolbar with buttons in separate list items', () => { + render(<PlayerToolbar id="song-1" />) + + // Each button should be in its own list item + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(2) + + // Verify both buttons are rendered + expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() + expect(screen.getByTestId('love-button')).toBeInTheDocument() + + // Verify mobile classes are applied + expect(listItems[0].className).toContain('mobileListItem') + expect(listItems[1].className).toContain('mobileListItem') + }) + + it('disables save queue button when isRadio is true', () => { + render(<PlayerToolbar id="song-1" isRadio={true} />) + + const saveQueueButton = screen.getByTestId('save-queue-button') + expect(saveQueueButton).toBeDisabled() + }) + + it('disables love button when conditions are met', () => { + useGetOne.mockReturnValue({ data: mockSongData, loading: true }) + + render(<PlayerToolbar id="song-1" />) + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + }) + + describe('Common behavior', () => { + it('renders global hotkeys in both layouts', () => { + // Test desktop layout + useMediaQuery.mockReturnValue(true) + render(<PlayerToolbar id="song-1" />) + expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() + + // Cleanup and test mobile layout + cleanup() + useMediaQuery.mockReturnValue(false) + render(<PlayerToolbar id="song-1" />) + expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() + }) + + it('disables buttons when id is not provided', () => { + render(<PlayerToolbar />) + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + }) +}) diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx index 60832eb40..d41b47b06 100644 --- a/ui/src/common/ArtistLinkField.jsx +++ b/ui/src/common/ArtistLinkField.jsx @@ -44,7 +44,9 @@ const parseAndReplaceArtists = ( result.push(displayAlbumArtist.slice(lastIndex, index)) } // Add the artist link - result.push(<ALink artist={artist} className={className} />) + result.push( + <ALink artist={artist} className={className} key={artist.id} />, + ) lastIndex = index + artist.name.length } }) diff --git a/ui/src/common/CollapsibleComment.jsx b/ui/src/common/CollapsibleComment.jsx index 8660d0492..77750e7f4 100644 --- a/ui/src/common/CollapsibleComment.jsx +++ b/ui/src/common/CollapsibleComment.jsx @@ -25,7 +25,10 @@ export const CollapsibleComment = ({ record }) => { const classes = useStyles() const [expanded, setExpanded] = useState(false) - const lines = record.comment.split('\n') + const lines = useMemo( + () => record.comment?.split('\n') || [], + [record.comment], + ) const formatted = useMemo(() => { return lines.map((line, idx) => ( <span key={record.id + '-comment-' + idx}> diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx index 1b440c51e..b29c1eee8 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.jsx @@ -38,15 +38,16 @@ export const RatingField = ({ const handleRating = useCallback( (e, val) => { - rate(val ?? 0, e.target.name) + const targetId = record.mediaFileId || record.id + rate(val ?? 0, targetId) }, - [rate], + [rate, record.mediaFileId, record.id], ) return ( <span onClick={(e) => stopPropagation(e)}> <Rating - name={record.id} + name={record.mediaFileId || record.id} className={clsx( className, classes.rating, diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx index f2227dc72..32fbeb243 100644 --- a/ui/src/common/SongContextMenu.jsx +++ b/ui/src/common/SongContextMenu.jsx @@ -1,7 +1,12 @@ import React, { useState } from 'react' import PropTypes from 'prop-types' import { useDispatch } from 'react-redux' -import { useNotify, usePermissions, useTranslate } from 'react-admin' +import { + useNotify, + usePermissions, + useTranslate, + useDataProvider, +} from 'react-admin' import { IconButton, Menu, MenuItem } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import MoreVertIcon from '@material-ui/icons/MoreVert' @@ -20,7 +25,7 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' -import { httpClient } from '../dataProvider' +import { useRedirect } from 'react-admin' const useStyles = makeStyles({ noWrap: { @@ -57,8 +62,13 @@ export const SongContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() + const dataProvider = useDataProvider() const [anchorEl, setAnchorEl] = useState(null) + const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null) + const [playlists, setPlaylists] = useState([]) + const [playlistsLoaded, setPlaylistsLoaded] = useState(false) const { permissions } = usePermissions() + const redirect = useRedirect() const options = { playNow: { @@ -87,6 +97,15 @@ export const SongContextMenu = ({ }), ), }, + showInPlaylist: { + enabled: true, + label: + translate('resources.song.actions.showInPlaylist') + + (playlists.length > 0 ? ' ►' : ''), + action: (record, e) => { + setPlaylistAnchorEl(e.currentTarget) + }, + }, share: { enabled: config.enableSharing, label: translate('ra.action.share'), @@ -113,8 +132,8 @@ export const SongContextMenu = ({ if (permissions === 'admin' && !record.missing) { try { let id = record.mediaFileId ?? record.id - const data = await httpClient(`/api/inspect?id=${id}`) - fullRecord = { ...record, rawTags: data.json.rawTags } + const data = await dataProvider.inspect(id) + fullRecord = { ...record, rawTags: data.data.rawTags } } catch (error) { notify( translate('ra.notification.http_error') + ': ' + error.message, @@ -134,6 +153,21 @@ export const SongContextMenu = ({ const handleClick = (e) => { setAnchorEl(e.currentTarget) + if (!playlistsLoaded) { + const id = record.mediaFileId || record.id + dataProvider + .getPlaylists(id) + .then((res) => { + setPlaylists(res.data) + setPlaylistsLoaded(true) + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error('Failed to fetch playlists:', error) + setPlaylists([]) + setPlaylistsLoaded(true) + }) + } e.stopPropagation() } @@ -144,12 +178,39 @@ export const SongContextMenu = ({ const handleItemClick = (e) => { e.preventDefault() - setAnchorEl(null) const key = e.target.getAttribute('value') - options[key].action(record) + const action = options[key].action + + if (key === 'showInPlaylist') { + // For showInPlaylist, we keep the main menu open and show submenu + action(record, e) + } else { + // For other actions, close the main menu + setAnchorEl(null) + action(record) + } e.stopPropagation() } + const handlePlaylistClose = (e) => { + setPlaylistAnchorEl(null) + if (e) { + e.stopPropagation() + } + } + + const handleMainMenuClose = (e) => { + setAnchorEl(null) + setPlaylistAnchorEl(null) // Close both menus + e.stopPropagation() + } + + const handlePlaylistClick = (id, e) => { + e.stopPropagation() + redirect(`/playlist/${id}/show`) + handlePlaylistClose() + } + const open = Boolean(anchorEl) if (!record) { @@ -170,17 +231,41 @@ export const SongContextMenu = ({ id={'menu' + record.id} anchorEl={anchorEl} open={open} - onClose={handleClose} + onClose={handleMainMenuClose} > {Object.keys(options).map( (key) => options[key].enabled && ( - <MenuItem value={key} key={key} onClick={handleItemClick}> + <MenuItem + value={key} + key={key} + onClick={handleItemClick} + disabled={key === 'showInPlaylist' && !playlists.length} + > {options[key].label} </MenuItem> ), )} </Menu> + <Menu + anchorEl={playlistAnchorEl} + open={Boolean(playlistAnchorEl)} + onClose={handlePlaylistClose} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + > + {playlists.map((p) => ( + <MenuItem key={p.id} onClick={(e) => handlePlaylistClick(p.id, e)}> + {p.name} + </MenuItem> + ))} + </Menu> </span> ) } diff --git a/ui/src/common/SongContextMenu.test.jsx b/ui/src/common/SongContextMenu.test.jsx new file mode 100644 index 000000000..ee6a358d8 --- /dev/null +++ b/ui/src/common/SongContextMenu.test.jsx @@ -0,0 +1,82 @@ +import React from 'react' +import { render, fireEvent, screen, waitFor } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { SongContextMenu } from './SongContextMenu' + +vi.mock('../dataProvider', () => ({ + httpClient: vi.fn(), +})) + +vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRedirect: () => (url) => { + window.location.hash = `#${url}` + }, + useDataProvider: () => ({ + getPlaylists: vi.fn().mockResolvedValue({ + data: [{ id: 'pl1', name: 'Pl 1' }], + }), + inspect: vi.fn().mockResolvedValue({ + data: { rawTags: {} }, + }), + }), + } +}) + +describe('SongContextMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + window.location.hash = '' + }) + + it('navigates to playlist when selected', async () => { + render( + <TestContext> + <SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" /> + </TestContext>, + ) + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + fireEvent.click( + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + await waitFor(() => screen.getByText('Pl 1')) + fireEvent.click(screen.getByText('Pl 1')) + expect(window.location.hash).toBe('#/playlist/pl1/show') + }) + + it('stops event propagation when playlist submenu is closed', async () => { + const mockOnClick = vi.fn() + render( + <TestContext> + <div onClick={mockOnClick}> + <SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" /> + </div> + </TestContext>, + ) + + // Open main menu + fireEvent.click(screen.getAllByRole('button')[1]) + await waitFor(() => + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + + // Open playlist submenu + fireEvent.click( + screen.getByText(/resources\.song\.actions\.showInPlaylist/), + ) + await waitFor(() => screen.getByText('Pl 1')) + + // Click outside the playlist submenu (should close it without triggering parent click) + fireEvent.click(document.body) + + expect(mockOnClick).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx index 77e91b653..9b9ca18cd 100644 --- a/ui/src/common/SongInfo.jsx +++ b/ui/src/common/SongInfo.jsx @@ -138,7 +138,7 @@ export const SongInfo = (props) => { </Tabs> )} <div - hidden={tab == 1} + hidden={tab === 1} id="mapped-tags-body" aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined} > diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index f1d9a4fe9..2eb5d9eca 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -17,18 +17,42 @@ export const useRating = (resource, record) => { }, []) const refreshRating = useCallback(() => { - dataProvider - .getOne(resource, { id: record.id }) - .then(() => { - if (mountedRef.current) { - setLoading(false) - } - }) - .catch((e) => { - // eslint-disable-next-line no-console - console.log('Error encountered: ' + e) - }) - }, [dataProvider, record, resource]) + // For playlist tracks, refresh both resources to keep data in sync + if (record.mediaFileId) { + // This is a playlist track - refresh both the playlist track and the song + const promises = [ + dataProvider.getOne('song', { id: record.mediaFileId }), + dataProvider.getOne('playlistTrack', { + id: record.id, + filter: { playlist_id: record.playlistId }, + }), + ] + + Promise.all(promises) + .catch((e) => { + // eslint-disable-next-line no-console + console.log('Error encountered: ' + e) + }) + .finally(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + } else { + // Regular song or other resource + dataProvider + .getOne(resource, { id: record.id }) + .catch((e) => { + // eslint-disable-next-line no-console + console.log('Error encountered: ' + e) + }) + .finally(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + } + }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource]) const rate = (val, id) => { setLoading(true) diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js new file mode 100644 index 000000000..b1353512e --- /dev/null +++ b/ui/src/common/useRating.test.js @@ -0,0 +1,165 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { useRating } from './useRating' +import subsonic from '../subsonic' +import { useDataProvider } from 'react-admin' + +vi.mock('../subsonic', () => ({ + default: { + setRating: vi.fn(() => Promise.resolve()), + }, +})) + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useDataProvider: vi.fn(), + useNotify: vi.fn(() => vi.fn()), + } +}) + +describe('useRating', () => { + let getOne + beforeEach(() => { + getOne = vi.fn(() => Promise.resolve()) + useDataProvider.mockReturnValue({ getOne }) + vi.clearAllMocks() + }) + + it('returns rating value from record', () => { + const record = { id: 'sg-1', rating: 3 } + const { result } = renderHook(() => useRating('song', record)) + const [rate, rating, loading] = result.current + expect(rating).toBe(3) + expect(loading).toBe(false) + expect(typeof rate).toBe('function') + }) + + it('sets rating using targetId and calls setRating API', async () => { + const record = { id: 'sg-1', rating: 0 } + const { result } = renderHook(() => useRating('song', record)) + await act(async () => { + await result.current[0](4, 'sg-1') + }) + expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('handles zero rating (unrate)', async () => { + const record = { id: 'sg-1', rating: 5 } + const { result } = renderHook(() => useRating('song', record)) + await act(async () => { + await result.current[0](0, 'sg-1') + }) + expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 0) + }) + + describe('playlist track scenarios', () => { + it('refreshes both playlist track and song for playlist tracks', async () => { + const record = { + id: 'pt-1', + mediaFileId: 'sg-1', + playlistId: 'pl-1', + rating: 2, + } + const { result } = renderHook(() => useRating('playlistTrack', record)) + await act(async () => { + await result.current[0](5, 'sg-1') + }) + + // Should rate using the media file ID + expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) + + // Should refresh both the playlist track and the song + expect(getOne).toHaveBeenCalledTimes(2) + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-1', + filter: { playlist_id: 'pl-1' }, + }) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('includes playlist_id filter when refreshing playlist tracks', async () => { + const record = { + id: 'pt-5', + mediaFileId: 'sg-10', + playlistId: 'pl-123', + rating: 1, + } + const { result } = renderHook(() => useRating('playlistTrack', record)) + await act(async () => { + await result.current[0](3, 'sg-10') + }) + + // Should rate using the media file ID + expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) + + // Should refresh playlist track with correct playlist_id filter + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-5', + filter: { playlist_id: 'pl-123' }, + }) + // Should also refresh the underlying song + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + }) + + it('only refreshes original resource when no mediaFileId present', async () => { + const record = { id: 'sg-1', rating: 4 } + const { result } = renderHook(() => useRating('song', record)) + await act(async () => { + await result.current[0](2, 'sg-1') + }) + + // Should only refresh the original resource (song) + expect(getOne).toHaveBeenCalledTimes(1) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('does not include playlist_id filter for non-playlist resources', async () => { + const record = { id: 'sg-1', rating: 0 } + const { result } = renderHook(() => useRating('song', record)) + await act(async () => { + await result.current[0](5, 'sg-1') + }) + + // Should refresh without any filter + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + }) + + describe('component integration scenarios', () => { + it('handles mediaFileId fallback correctly for playlist tracks', async () => { + const record = { + id: 'pt-1', + mediaFileId: 'sg-1', + playlistId: 'pl-1', + rating: 0, + } + const { result } = renderHook(() => useRating('playlistTrack', record)) + + // Simulate RatingField component behavior: uses mediaFileId || record.id + const targetId = record.mediaFileId || record.id + await act(async () => { + await result.current[0](4, targetId) + }) + + expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4) + }) + + it('handles regular song rating without mediaFileId', async () => { + const record = { id: 'sg-1', rating: 2 } + const { result } = renderHook(() => useRating('song', record)) + + // Simulate RatingField component behavior: uses mediaFileId || record.id + const targetId = record.mediaFileId || record.id + await act(async () => { + await result.current[0](5, targetId) + }) + + expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) + expect(getOne).toHaveBeenCalledTimes(1) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + }) +}) diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx index 6379d587e..3f98a2e21 100644 --- a/ui/src/common/useToggleLove.jsx +++ b/ui/src/common/useToggleLove.jsx @@ -17,18 +17,38 @@ export const useToggleLove = (resource, record = {}) => { const dataProvider = useDataProvider() const refreshRecord = useCallback(() => { - dataProvider.getOne(resource, { id: record.id }).then(() => { - if (mountedRef.current) { - setLoading(false) - } - }) - }, [dataProvider, record.id, resource]) + const promises = [] + + // Always refresh the original resource + const params = { id: record.id } + if (record.playlistId) { + params.filter = { playlist_id: record.playlistId } + } + promises.push(dataProvider.getOne(resource, params)) + + // If we have a mediaFileId, also refresh the song + if (record.mediaFileId) { + promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) + } + + Promise.all(promises) + .catch((e) => { + // eslint-disable-next-line no-console + console.log('Error encountered: ' + e) + }) + .finally(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource]) const toggleLove = () => { const toggle = record.starred ? subsonic.unstar : subsonic.star + const id = record.mediaFileId || record.id setLoading(true) - toggle(record.id) + toggle(id) .then(refreshRecord) .catch((e) => { // eslint-disable-next-line no-console diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js new file mode 100644 index 000000000..640e9ff89 --- /dev/null +++ b/ui/src/common/useToggleLove.test.js @@ -0,0 +1,136 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { useToggleLove } from './useToggleLove' +import subsonic from '../subsonic' +import { useDataProvider } from 'react-admin' + +vi.mock('../subsonic', () => ({ + default: { + star: vi.fn(() => Promise.resolve()), + unstar: vi.fn(() => Promise.resolve()), + }, +})) + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useDataProvider: vi.fn(), + useNotify: vi.fn(() => vi.fn()), + } +}) + +describe('useToggleLove', () => { + let getOne + beforeEach(() => { + getOne = vi.fn(() => Promise.resolve()) + useDataProvider.mockReturnValue({ getOne }) + vi.clearAllMocks() + }) + + it('uses mediaFileId when present', async () => { + const record = { id: 'pt-1', mediaFileId: 'sg-1', starred: false } + const { result } = renderHook(() => useToggleLove('song', record)) + await act(async () => { + await result.current[0]() + }) + expect(subsonic.star).toHaveBeenCalledWith('sg-1') + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('falls back to id when mediaFileId not present', async () => { + const record = { id: 'sg-1', starred: false } + const { result } = renderHook(() => useToggleLove('song', record)) + await act(async () => { + await result.current[0]() + }) + expect(subsonic.star).toHaveBeenCalledWith('sg-1') + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('calls unstar when record is already loved', async () => { + const record = { id: 'sg-1', starred: true } + const { result } = renderHook(() => useToggleLove('song', record)) + await act(async () => { + await result.current[0]() + }) + expect(subsonic.unstar).toHaveBeenCalledWith('sg-1') + }) + + describe('playlist track scenarios', () => { + it('refreshes both playlist track and song for playlist tracks', async () => { + const record = { + id: 'pt-1', + mediaFileId: 'sg-1', + playlistId: 'pl-1', + starred: false, + } + const { result } = renderHook(() => + useToggleLove('playlistTrack', record), + ) + await act(async () => { + await result.current[0]() + }) + + // Should star using the media file ID + expect(subsonic.star).toHaveBeenCalledWith('sg-1') + + // Should refresh both the playlist track and the song + expect(getOne).toHaveBeenCalledTimes(2) + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-1', + filter: { playlist_id: 'pl-1' }, + }) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('includes playlist_id filter when refreshing playlist tracks', async () => { + const record = { + id: 'pt-5', + mediaFileId: 'sg-10', + playlistId: 'pl-123', + starred: true, + } + const { result } = renderHook(() => + useToggleLove('playlistTrack', record), + ) + await act(async () => { + await result.current[0]() + }) + + // Should unstar using the media file ID + expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') + + // Should refresh playlist track with correct playlist_id filter + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-5', + filter: { playlist_id: 'pl-123' }, + }) + // Should also refresh the underlying song + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + }) + + it('only refreshes original resource when no mediaFileId present', async () => { + const record = { id: 'sg-1', starred: false } + const { result } = renderHook(() => useToggleLove('song', record)) + await act(async () => { + await result.current[0]() + }) + + // Should only refresh the original resource (song) + expect(getOne).toHaveBeenCalledTimes(1) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('does not include playlist_id filter for non-playlist resources', async () => { + const record = { id: 'sg-1', starred: false } + const { result } = renderHook(() => useToggleLove('song', record)) + await act(async () => { + await result.current[0]() + }) + + // Should refresh without any filter + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + }) +}) diff --git a/ui/src/config.js b/ui/src/config.js index 92ce07893..a53a97de7 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -29,7 +29,10 @@ const defaultConfig = { listenBrainzEnabled: true, enableExternalServices: true, enableCoverAnimation: true, + enableNowPlaying: true, devShowArtistPage: true, + devUIShowConfig: true, + devNewEventStream: false, enableReplayGain: true, defaultDownsamplingFormat: 'opus', publicBaseUrl: '/share', diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index f387136cb..257a274e8 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -36,9 +36,9 @@ const mapResource = (resource, params) => { } const callDeleteMany = (resource, params) => { - const ids = params.ids.map((id) => `id=${id}`) - const idsParam = ids.join('&') - return httpClient(`${REST_URL}/${resource}?${idsParam}`, { + const ids = (params.ids || []).map((id) => `id=${id}`) + const query = ids.length > 0 ? `?${ids.join('&')}` : '' + return httpClient(`${REST_URL}/${resource}${query}`, { method: 'DELETE', }).then((response) => ({ data: response.json.ids || [] })) } @@ -90,6 +90,16 @@ const wrapperDataProvider = { body: JSON.stringify(data), }).then(({ json }) => ({ data: json })) }, + getPlaylists: (songId) => { + return httpClient(`${REST_URL}/song/${songId}/playlists`).then( + ({ json }) => ({ data: json }), + ) + }, + inspect: (songId) => { + return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ + data: json, + })) + }, } export default wrapperDataProvider diff --git a/ui/src/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx index 4f074002b..661462b9b 100644 --- a/ui/src/dialogs/AboutDialog.jsx +++ b/ui/src/dialogs/AboutDialog.jsx @@ -10,14 +10,63 @@ import TableRow from '@material-ui/core/TableRow' import TableCell from '@material-ui/core/TableCell' import Paper from '@material-ui/core/Paper' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import Button from '@material-ui/core/Button' import { humanize, underscore } from 'inflection' -import { useGetOne, usePermissions, useTranslate } from 'react-admin' +import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin' +import { Tabs, Tab } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import config from '../config' import { DialogTitle } from './DialogTitle' import { DialogContent } from './DialogContent' import { INSIGHTS_DOC_URL } from '../consts.js' import subsonic from '../subsonic/index.js' import { Typography } from '@material-ui/core' +import TableHead from '@material-ui/core/TableHead' +import { configToToml, separateAndSortConfigs } from './aboutUtils' + +const useStyles = makeStyles((theme) => ({ + configNameColumn: { + maxWidth: '200px', + width: '200px', + wordWrap: 'break-word', + overflowWrap: 'break-word', + }, + envVarColumn: { + maxWidth: '250px', + width: '250px', + fontFamily: 'monospace', + wordWrap: 'break-word', + overflowWrap: 'break-word', + }, + copyButton: { + marginBottom: theme.spacing(2), + marginTop: theme.spacing(1), + }, + devSectionHeader: { + '& td': { + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + borderTop: `2px solid ${theme.palette.divider}`, + borderBottom: `1px solid ${theme.palette.divider}`, + textAlign: 'left', + fontWeight: 600, + }, + }, + configContainer: { + paddingTop: theme.spacing(1), + }, + tableContainer: { + maxHeight: '60vh', + overflow: 'auto', + }, + devFlagsTitle: { + fontWeight: 600, + }, + expandableDialog: { + transition: 'max-width 300ms ease', + }, +})) const links = { homepage: 'navidrome.org', @@ -54,7 +103,6 @@ const LinkToVersion = ({ version }) => { const ShowVersion = ({ uiVersion, serverVersion }) => { const translate = useTranslate() - const showRefresh = uiVersion !== serverVersion return ( @@ -73,12 +121,16 @@ const ShowVersion = ({ uiVersion, serverVersion }) => { UI {translate('menu.version')}: </TableCell> <TableCell align="left"> - <LinkToVersion version={uiVersion} /> - <Link onClick={() => window.location.reload()}> - <Typography variant={'caption'}> - {' ' + translate('ra.notification.new_version')} - </Typography> - </Link> + <div> + <LinkToVersion version={uiVersion} /> + </div> + <div> + <Link onClick={() => window.location.reload()}> + <Typography variant={'caption'}> + {translate('ra.notification.new_version')} + </Typography> + </Link> + </div> </TableCell> </TableRow> )} @@ -86,11 +138,285 @@ const ShowVersion = ({ uiVersion, serverVersion }) => { ) } -const AboutDialog = ({ open, onClose }) => { +const AboutTabContent = ({ + uiVersion, + serverVersion, + insightsData, + loading, + permissions, +}) => { const translate = useTranslate() + + const lastRun = !loading && insightsData?.lastRun + let insightsStatus = 'N/A' + if (lastRun === 'disabled') { + insightsStatus = translate('about.links.insights.disabled') + } else if (lastRun && lastRun?.startsWith('1969-12-31')) { + insightsStatus = translate('about.links.insights.waiting') + } else if (lastRun) { + insightsStatus = lastRun + } + + return ( + <Table aria-label={translate('menu.about')} size="small"> + <TableBody> + <ShowVersion uiVersion={uiVersion} serverVersion={serverVersion} /> + {Object.keys(links).map((key) => { + return ( + <TableRow key={key}> + <TableCell align="right" component="th" scope="row"> + {translate(`about.links.${key}`, { + _: humanize(underscore(key)), + })} + : + </TableCell> + <TableCell align="left"> + <Link + href={`https://${links[key]}`} + target="_blank" + rel="noopener noreferrer" + > + {links[key]} + </Link> + </TableCell> + </TableRow> + ) + })} + {permissions === 'admin' ? ( + <TableRow> + <TableCell align="right" component="th" scope="row"> + {translate(`about.links.lastInsightsCollection`)}: + </TableCell> + <TableCell align="left"> + <Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link> + </TableCell> + </TableRow> + ) : null} + <TableRow> + <TableCell align="right" component="th" scope="row"> + <Link + href={'https://github.com/sponsors/deluan'} + target="_blank" + rel="noopener noreferrer" + > + <IconButton size={'small'}> + <FavoriteBorderIcon fontSize={'small'} /> + </IconButton> + </Link> + </TableCell> + <TableCell align="left"> + <Link + href={'https://ko-fi.com/deluan'} + target="_blank" + rel="noopener noreferrer" + > + ko-fi.com/deluan + </Link> + </TableCell> + </TableRow> + </TableBody> + </Table> + ) +} + +const ConfigTabContent = ({ configData }) => { + const classes = useStyles() + const translate = useTranslate() + const notify = useNotify() + + if (!configData || !configData.config) { + return null + } + + // Use the shared separation and sorting logic + const { regularConfigs, devConfigs } = separateAndSortConfigs( + configData.config, + ) + + const handleCopyToml = async () => { + try { + const tomlContent = configToToml(configData, translate) + await navigator.clipboard.writeText(tomlContent) + notify(translate('about.config.exportSuccess'), 'info') + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to copy TOML:', err) + notify(translate('about.config.exportFailed'), 'error') + } + } + + return ( + <div className={classes.configContainer}> + <Button + variant="outlined" + startIcon={<FileCopyIcon />} + onClick={handleCopyToml} + className={classes.copyButton} + disabled={!configData} + size="small" + > + {translate('about.config.exportToml')} + </Button> + <TableContainer className={classes.tableContainer}> + <Table size="small" stickyHeader> + <TableHead> + <TableRow> + <TableCell + align="left" + component="th" + scope="col" + className={classes.configNameColumn} + > + {translate('about.config.configName')} + </TableCell> + <TableCell align="left" component="th" scope="col"> + {translate('about.config.environmentVariable')} + </TableCell> + <TableCell align="left" component="th" scope="col"> + {translate('about.config.currentValue')} + </TableCell> + </TableRow> + </TableHead> + <TableBody> + {configData?.configFile && ( + <TableRow> + <TableCell + align="left" + component="th" + scope="row" + className={classes.configNameColumn} + > + {translate('about.config.configurationFile')} + </TableCell> + <TableCell align="left" className={classes.envVarColumn}> + ND_CONFIGFILE + </TableCell> + <TableCell align="left">{configData.configFile}</TableCell> + </TableRow> + )} + {regularConfigs.map(({ key, envVar, value }) => ( + <TableRow key={key}> + <TableCell + align="left" + component="th" + scope="row" + className={classes.configNameColumn} + > + {key} + </TableCell> + <TableCell align="left" className={classes.envVarColumn}> + {envVar} + </TableCell> + <TableCell align="left">{String(value)}</TableCell> + </TableRow> + ))} + {devConfigs.length > 0 && ( + <TableRow className={classes.devSectionHeader}> + <TableCell colSpan={3}> + <Typography + variant="subtitle1" + component="div" + className={classes.devFlagsTitle} + > + 🚧 {translate('about.config.devFlagsHeader')} + </Typography> + </TableCell> + </TableRow> + )} + {devConfigs.map(({ key, envVar, value }) => ( + <TableRow key={key}> + <TableCell + align="left" + component="th" + scope="row" + className={classes.configNameColumn} + > + {key} + </TableCell> + <TableCell align="left" className={classes.envVarColumn}> + {envVar} + </TableCell> + <TableCell align="left">{String(value)}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </TableContainer> + </div> + ) +} + +const TabContent = ({ + tab, + setTab, + showConfigTab, + uiVersion, + serverVersion, + insightsData, + loading, + permissions, + configData, +}) => { + const translate = useTranslate() + + return ( + <TableContainer component={Paper}> + {showConfigTab && ( + <Tabs value={tab} onChange={(_, value) => setTab(value)}> + <Tab + label={translate('about.tabs.about')} + id="about-tab" + aria-controls="about-panel" + /> + <Tab + label={translate('about.tabs.config')} + id="config-tab" + aria-controls="config-panel" + /> + </Tabs> + )} + <div + id="about-panel" + role="tabpanel" + aria-labelledby="about-tab" + hidden={showConfigTab && tab === 1} + > + <AboutTabContent + uiVersion={uiVersion} + serverVersion={serverVersion} + insightsData={insightsData} + loading={loading} + permissions={permissions} + /> + </div> + {showConfigTab && ( + <div + id="config-panel" + role="tabpanel" + aria-labelledby="config-tab" + hidden={tab === 0} + > + <ConfigTabContent configData={configData} /> + </div> + )} + </TableContainer> + ) +} + +const AboutDialog = ({ open, onClose }) => { + const classes = useStyles() const { permissions } = usePermissions() - const { data, loading } = useGetOne('insights', 'insights_status') + const { data: insightsData, loading } = useGetOne( + 'insights', + 'insights_status', + ) const [serverVersion, setServerVersion] = useState('') + const showConfigTab = permissions === 'admin' && config.devUIShowConfig + const [tab, setTab] = useState(0) + const { data: configData } = useGetOne('config', 'config', { + enabled: showConfigTab, + }) + const expanded = showConfigTab && tab === 1 const uiVersion = config.version useEffect(() => { @@ -108,85 +434,30 @@ const AboutDialog = ({ open, onClose }) => { }) }, [setServerVersion]) - const lastRun = !loading && data?.lastRun - let insightsStatus = 'N/A' - if (lastRun === 'disabled') { - insightsStatus = translate('about.links.insights.disabled') - } else if (lastRun && lastRun?.startsWith('1969-12-31')) { - insightsStatus = translate('about.links.insights.waiting') - } else if (lastRun) { - insightsStatus = lastRun - } - return ( - <Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}> + <Dialog + onClose={onClose} + aria-labelledby="about-dialog-title" + open={open} + fullWidth={true} + maxWidth={expanded ? 'lg' : 'sm'} + className={classes.expandableDialog} + > <DialogTitle id="about-dialog-title" onClose={onClose}> Navidrome Music Server </DialogTitle> <DialogContent dividers> - <TableContainer component={Paper}> - <Table aria-label={translate('menu.about')} size="small"> - <TableBody> - <ShowVersion - uiVersion={uiVersion} - serverVersion={serverVersion} - /> - {Object.keys(links).map((key) => { - return ( - <TableRow key={key}> - <TableCell align="right" component="th" scope="row"> - {translate(`about.links.${key}`, { - _: humanize(underscore(key)), - })} - : - </TableCell> - <TableCell align="left"> - <Link - href={`https://${links[key]}`} - target="_blank" - rel="noopener noreferrer" - > - {links[key]} - </Link> - </TableCell> - </TableRow> - ) - })} - {permissions === 'admin' ? ( - <TableRow> - <TableCell align="right" component="th" scope="row"> - {translate(`about.links.lastInsightsCollection`)}: - </TableCell> - <TableCell align="left"> - <Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link> - </TableCell> - </TableRow> - ) : null} - <TableRow> - <TableCell align="right" component="th" scope="row"> - <Link - href={'https://github.com/sponsors/deluan'} - target="_blank" - rel="noopener noreferrer" - > - <IconButton size={'small'}> - <FavoriteBorderIcon fontSize={'small'} /> - </IconButton> - </Link> - </TableCell> - <TableCell align="left"> - <Link - href={'https://ko-fi.com/deluan'} - target="_blank" - rel="noopener noreferrer" - > - ko-fi.com/deluan - </Link> - </TableCell> - </TableRow> - </TableBody> - </Table> - </TableContainer> + <TabContent + tab={tab} + setTab={setTab} + showConfigTab={showConfigTab} + uiVersion={uiVersion} + serverVersion={serverVersion} + insightsData={insightsData} + loading={loading} + permissions={permissions} + configData={configData} + /> </DialogContent> </Dialog> ) diff --git a/ui/src/dialogs/AddToPlaylistDialog.jsx b/ui/src/dialogs/AddToPlaylistDialog.jsx index d1f80a432..91521d1ce 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.jsx +++ b/ui/src/dialogs/AddToPlaylistDialog.jsx @@ -12,6 +12,7 @@ import { DialogActions, DialogContent, DialogTitle, + makeStyles, } from '@material-ui/core' import { closeAddToPlaylist, @@ -23,7 +24,21 @@ import DuplicateSongDialog from './DuplicateSongDialog' import { httpClient } from '../dataProvider' import { REST_URL } from '../consts' +const useStyles = makeStyles({ + dialogPaper: { + height: '26em', + maxHeight: '26em', + }, + dialogContent: { + height: '17.5em', + overflowY: 'auto', + paddingTop: '0.5em', + paddingBottom: '0.5em', + }, +}) + export const AddToPlaylistDialog = () => { + const classes = useStyles() const { open, selectedIds, onSuccess, duplicateSong, duplicateIds } = useSelector((state) => state.addToPlaylistDialog) const dispatch = useDispatch() @@ -145,11 +160,14 @@ export const AddToPlaylistDialog = () => { aria-labelledby="form-dialog-new-playlist" fullWidth={true} maxWidth={'sm'} + classes={{ + paper: classes.dialogPaper, + }} > <DialogTitle id="form-dialog-new-playlist"> {translate('resources.playlist.actions.selectPlaylist')} </DialogTitle> - <DialogContent> + <DialogContent className={classes.dialogContent}> <SelectPlaylistInput onChange={handleChange} /> </DialogContent> <DialogActions> diff --git a/ui/src/dialogs/AddToPlaylistDialog.test.jsx b/ui/src/dialogs/AddToPlaylistDialog.test.jsx index a019ee03c..60d3cca0d 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.test.jsx +++ b/ui/src/dialogs/AddToPlaylistDialog.test.jsx @@ -88,12 +88,18 @@ describe('AddToPlaylistDialog', () => { createTestUtils(mockDataProvider) + // Filter to see sample playlists let textBox = screen.getByRole('textbox') fireEvent.change(textBox, { target: { value: 'sample' } }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'Enter' }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'Enter' }) + + // Click on first playlist + const firstPlaylist = screen.getByText('sample playlist 1') + fireEvent.click(firstPlaylist) + + // Click on second playlist + const secondPlaylist = screen.getByText('sample playlist 2') + fireEvent.click(secondPlaylist) + await waitFor(() => { expect(screen.getByTestId('playlist-add')).not.toBeDisabled() }) @@ -133,12 +139,11 @@ describe('AddToPlaylistDialog', () => { createTestUtils(mockDataProvider) + // Type a new playlist name and press Enter to create it let textBox = screen.getByRole('textbox') fireEvent.change(textBox, { target: { value: 'sample' } }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) fireEvent.keyDown(textBox, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByTestId('playlist-add')).not.toBeDisabled() }) @@ -171,14 +176,15 @@ describe('AddToPlaylistDialog', () => { createTestUtils(mockDataProvider) + // Create first playlist let textBox = screen.getByRole('textbox') fireEvent.change(textBox, { target: { value: 'sample' } }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) fireEvent.keyDown(textBox, { key: 'Enter' }) + + // Create second playlist fireEvent.change(textBox, { target: { value: 'new playlist' } }) fireEvent.keyDown(textBox, { key: 'Enter' }) + await waitFor(() => { expect(screen.getByTestId('playlist-add')).not.toBeDisabled() }) diff --git a/ui/src/dialogs/Dialogs.jsx b/ui/src/dialogs/Dialogs.jsx index 74f5a094d..f37c55823 100644 --- a/ui/src/dialogs/Dialogs.jsx +++ b/ui/src/dialogs/Dialogs.jsx @@ -2,10 +2,12 @@ import { AddToPlaylistDialog } from './AddToPlaylistDialog' import DownloadMenuDialog from './DownloadMenuDialog' import { HelpDialog } from './HelpDialog' import { ShareDialog } from './ShareDialog' +import { SaveQueueDialog } from './SaveQueueDialog' export const Dialogs = (props) => ( <> <AddToPlaylistDialog /> + <SaveQueueDialog /> <DownloadMenuDialog /> <HelpDialog /> <ShareDialog /> diff --git a/ui/src/dialogs/SaveQueueDialog.jsx b/ui/src/dialogs/SaveQueueDialog.jsx new file mode 100644 index 000000000..f916a0793 --- /dev/null +++ b/ui/src/dialogs/SaveQueueDialog.jsx @@ -0,0 +1,120 @@ +import React, { useState, useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + useDataProvider, + useNotify, + useTranslate, + useRefresh, +} from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + CircularProgress, +} from '@material-ui/core' +import { closeSaveQueueDialog } from '../actions' +import { useHistory } from 'react-router-dom' + +export const SaveQueueDialog = () => { + const dispatch = useDispatch() + const { open } = useSelector((state) => state.saveQueueDialog) + const queue = useSelector((state) => state.player.queue) + const [name, setName] = useState('') + const dataProvider = useDataProvider() + const notify = useNotify() + const translate = useTranslate() + const history = useHistory() + const [isSaving, setIsSaving] = useState(false) + const refresh = useRefresh() + + const handleClose = useCallback( + (e) => { + setName('') + dispatch(closeSaveQueueDialog()) + e.stopPropagation() + }, + [dispatch], + ) + + const handleSave = useCallback(() => { + setIsSaving(true) + const ids = queue.map((item) => item.trackId) + dataProvider + .create('playlist', { data: { name } }) + .then((res) => { + const playlistId = res.data.id + if (ids.length) { + return dataProvider + .create('playlistTrack', { + data: { ids }, + filter: { playlist_id: playlistId }, + }) + .then(() => res) + } + return res + }) + .then((res) => { + notify('ra.notification.created', { + type: 'info', + messageArgs: { smart_count: 1 }, + }) + dispatch(closeSaveQueueDialog()) + refresh() + history.push(`/playlist/${res.data.id}/show`) + }) + .catch(() => notify('ra.page.error', { type: 'warning' })) + .finally(() => setIsSaving(false)) + }, [dataProvider, dispatch, notify, queue, name, history, refresh]) + + const handleKeyPress = useCallback( + (e) => { + if (e.key === 'Enter' && name.trim() !== '') { + handleSave() + } + }, + [handleSave, name], + ) + + return ( + <Dialog + open={open} + onClose={isSaving ? undefined : handleClose} + aria-labelledby="save-queue-dialog" + fullWidth={true} + maxWidth={'sm'} + > + <DialogTitle id="save-queue-dialog"> + {translate('resources.playlist.actions.saveQueue', { _: 'Save Queue' })} + </DialogTitle> + <DialogContent> + <TextField + value={name} + onChange={(e) => setName(e.target.value)} + onKeyPress={handleKeyPress} + autoFocus + fullWidth + variant={'outlined'} + label={translate('resources.playlist.fields.name')} + disabled={isSaving} + /> + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="primary" disabled={isSaving}> + {translate('ra.action.cancel')} + </Button> + <Button + onClick={handleSave} + color="primary" + disabled={name.trim() === '' || isSaving} + data-testid="save-queue-save" + startIcon={isSaving ? <CircularProgress size={20} /> : null} + > + {translate('ra.action.save')} + </Button> + </DialogActions> + </Dialog> + ) +} diff --git a/ui/src/dialogs/SaveQueueDialog.test.jsx b/ui/src/dialogs/SaveQueueDialog.test.jsx new file mode 100644 index 000000000..c3a91385f --- /dev/null +++ b/ui/src/dialogs/SaveQueueDialog.test.jsx @@ -0,0 +1,91 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { DataProviderContext } from 'react-admin' +import { + cleanup, + fireEvent, + render, + waitFor, + screen, +} from '@testing-library/react' +import { SaveQueueDialog } from './SaveQueueDialog' +import { describe, afterEach, it, expect, vi, beforeAll } from 'vitest' + +const queue = [{ trackId: 'song-1' }, { trackId: 'song-2' }] + +const createTestUtils = (mockDataProvider) => + render( + <DataProviderContext.Provider value={mockDataProvider}> + <TestContext + initialState={{ + saveQueueDialog: { open: true }, + player: { queue }, + admin: { ui: { optimistic: false } }, + }} + > + <SaveQueueDialog /> + </TestContext> + </DataProviderContext.Provider>, + ) + +// Mock useHistory to update window.location.hash on push +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useHistory: () => ({ + push: (url) => { + window.location.hash = `#${url}` + }, + }), + } +}) + +beforeAll(() => { + // No need to patch pushState anymore +}) + +describe('SaveQueueDialog', () => { + afterEach(cleanup) + + it('creates playlist and saves queue', async () => { + const mockDataProvider = { + create: vi + .fn() + .mockResolvedValueOnce({ data: { id: 'created-id' } }) + .mockResolvedValueOnce({ data: { id: 'pt-id' } }), + } + + createTestUtils(mockDataProvider) + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'my playlist' }, + }) + fireEvent.click(screen.getByTestId('save-queue-save')) + + await waitFor(() => { + expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', { + data: { name: 'my playlist' }, + }) + }) + await waitFor(() => { + expect(mockDataProvider.create).toHaveBeenNthCalledWith( + 2, + 'playlistTrack', + { + data: { ids: ['song-1', 'song-2'] }, + filter: { playlist_id: 'created-id' }, + }, + ) + }) + await waitFor(() => { + expect(window.location.hash).toBe('#/playlist/created-id/show') + }) + }) + + it('disables save button when name is empty', () => { + const mockDataProvider = { create: vi.fn() } + createTestUtils(mockDataProvider) + expect(screen.getByTestId('save-queue-save')).toBeDisabled() + }) +}) diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 372d5f408..0e0636924 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -1,26 +1,265 @@ -import React from 'react' +import React, { useState } from 'react' import TextField from '@material-ui/core/TextField' import Checkbox from '@material-ui/core/Checkbox' import CheckBoxIcon from '@material-ui/icons/CheckBox' import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' -import Autocomplete, { - createFilterOptions, -} from '@material-ui/lab/Autocomplete' +import { + List, + ListItem, + ListItemIcon, + ListItemText, + Typography, + Box, + InputAdornment, + IconButton, +} from '@material-ui/core' +import AddIcon from '@material-ui/icons/Add' import { useGetList, useTranslate } from 'react-admin' import PropTypes from 'prop-types' import { isWritable } from '../common' import { makeStyles } from '@material-ui/core' -const filter = createFilterOptions() +const useStyles = makeStyles((theme) => ({ + root: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + }, + searchField: { + marginBottom: theme.spacing(2), + width: '100%', + flexShrink: 0, + }, + playlistList: { + flex: 1, + minHeight: 0, + overflow: 'auto', + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + }, + listItem: { + paddingTop: 0, + paddingBottom: 0, + }, + createIcon: { + fontSize: '1.25rem', + margin: '9px', + }, + selectedPlaylistsContainer: { + marginTop: theme.spacing(2), + flexShrink: 0, + maxHeight: '30%', + overflow: 'auto', + }, + selectedPlaylist: { + display: 'inline-flex', + alignItems: 'center', + margin: theme.spacing(0.5), + padding: theme.spacing(0.5, 1), + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + borderRadius: theme.shape.borderRadius, + fontSize: '0.875rem', + }, + removeButton: { + marginLeft: theme.spacing(0.5), + padding: 2, + color: 'inherit', + }, + emptyMessage: { + padding: theme.spacing(2), + textAlign: 'center', + color: theme.palette.text.secondary, + }, +})) -const useStyles = makeStyles({ - root: { width: '100%' }, - checkbox: { marginRight: 8 }, -}) +const PlaylistSearchField = ({ + searchText, + onSearchChange, + onCreateNew, + onKeyDown, + canCreateNew, +}) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <TextField + autoFocus + variant="outlined" + className={classes.searchField} + label={translate('resources.playlist.fields.name')} + value={searchText} + onChange={(e) => onSearchChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={translate('resources.playlist.actions.searchOrCreate')} + InputProps={{ + endAdornment: canCreateNew && ( + <InputAdornment position="end"> + <IconButton + onClick={onCreateNew} + size="small" + title={translate('resources.playlist.actions.addNewPlaylist', { + name: searchText, + })} + > + <AddIcon /> + </IconButton> + </InputAdornment> + ), + }} + /> + ) +} + +const EmptyPlaylistMessage = ({ searchText, canCreateNew }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <div className={classes.emptyMessage}> + <Typography variant="body2"> + {searchText + ? translate('resources.playlist.message.noPlaylistsFound') + : translate('resources.playlist.message.noPlaylists')} + </Typography> + {canCreateNew && ( + <Typography variant="body2" color="primary"> + {translate('resources.playlist.actions.pressEnterToCreate')} + </Typography> + )} + </div> + ) +} + +const PlaylistListItem = ({ playlist, isSelected, onToggle }) => { + const classes = useStyles() + + return ( + <ListItem + className={classes.listItem} + button + onClick={() => onToggle(playlist)} + dense + > + <ListItemIcon> + <Checkbox + icon={<CheckBoxOutlineBlankIcon fontSize="small" />} + checkedIcon={<CheckBoxIcon fontSize="small" />} + checked={isSelected} + tabIndex={-1} + disableRipple + /> + </ListItemIcon> + <ListItemText primary={playlist.name} /> + </ListItem> + ) +} + +const CreatePlaylistItem = ({ searchText, onCreateNew }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <ListItem className={classes.listItem} button onClick={onCreateNew} dense> + <ListItemIcon> + <AddIcon className={classes.createIcon} /> + </ListItemIcon> + <ListItemText + primary={translate('resources.playlist.actions.addNewPlaylist', { + name: searchText, + })} + /> + </ListItem> + ) +} + +const PlaylistList = ({ + filteredOptions, + selectedPlaylists, + onPlaylistToggle, + searchText, + canCreateNew, + onCreateNew, +}) => { + const classes = useStyles() + + const isPlaylistSelected = (playlist) => + selectedPlaylists.some((p) => p.id === playlist.id) + + return ( + <List className={classes.playlistList}> + {filteredOptions.length === 0 ? ( + <EmptyPlaylistMessage + searchText={searchText} + canCreateNew={canCreateNew} + /> + ) : ( + filteredOptions.map((playlist) => ( + <PlaylistListItem + key={playlist.id} + playlist={playlist} + isSelected={isPlaylistSelected(playlist)} + onToggle={onPlaylistToggle} + /> + )) + )} + {canCreateNew && filteredOptions.length > 0 && ( + <CreatePlaylistItem searchText={searchText} onCreateNew={onCreateNew} /> + )} + </List> + ) +} + +const SelectedPlaylistChip = ({ playlist, onRemove }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <span className={classes.selectedPlaylist}> + {playlist.name} + <IconButton + className={classes.removeButton} + size="small" + onClick={() => onRemove(playlist)} + title={translate('resources.playlist.actions.removeFromSelection')} + > + {translate('resources.playlist.actions.removeSymbol')} + </IconButton> + </span> + ) +} + +const SelectedPlaylistsDisplay = ({ selectedPlaylists, onRemoveSelected }) => { + const classes = useStyles() + const translate = useTranslate() + + if (selectedPlaylists.length === 0) { + return null + } + + return ( + <Box className={classes.selectedPlaylistsContainer}> + <Box> + {selectedPlaylists.map((playlist, index) => ( + <SelectedPlaylistChip + key={playlist.id || `new-${index}`} + playlist={playlist} + onRemove={onRemoveSelected} + /> + ))} + </Box> + </Box> + ) +} export const SelectPlaylistInput = ({ onChange }) => { const classes = useStyles() - const translate = useTranslate() + const [searchText, setSearchText] = useState('') + const [selectedPlaylists, setSelectedPlaylists] = useState([]) + const { ids, data } = useGetList( 'playlist', { page: 1, perPage: -1 }, @@ -32,92 +271,131 @@ export const SelectPlaylistInput = ({ onChange }) => { ids && ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId)) - const handleOnChange = (event, newValue) => { - let newState = [] - if (newValue && newValue.length) { - newValue.forEach((playlistObject) => { - if (playlistObject.inputValue) { - newState.push({ - name: playlistObject.inputValue, - }) - } else if (typeof playlistObject === 'string') { - newState.push({ - name: playlistObject, - }) - } else { - newState.push(playlistObject) - } - }) + // Filter playlists based on search text + const filteredOptions = + options?.filter((option) => + option.name.toLowerCase().includes(searchText.toLowerCase()), + ) || [] + + const handlePlaylistToggle = (playlist) => { + const isSelected = selectedPlaylists.some((p) => p.id === playlist.id) + let newSelection + + if (isSelected) { + newSelection = selectedPlaylists.filter((p) => p.id !== playlist.id) + } else { + newSelection = [...selectedPlaylists, playlist] } - onChange(newState) + + setSelectedPlaylists(newSelection) + onChange(newSelection) } - const icon = <CheckBoxOutlineBlankIcon fontSize="small" /> - const checkedIcon = <CheckBoxIcon fontSize="small" /> + const handleRemoveSelected = (playlistToRemove) => { + const newSelection = selectedPlaylists.filter( + (p) => p.id !== playlistToRemove.id, + ) + setSelectedPlaylists(newSelection) + onChange(newSelection) + } + + const handleCreateNew = () => { + if (searchText.trim()) { + const newPlaylist = { name: searchText.trim() } + const newSelection = [...selectedPlaylists, newPlaylist] + setSelectedPlaylists(newSelection) + onChange(newSelection) + setSearchText('') + } + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && searchText.trim()) { + e.preventDefault() + handleCreateNew() + } + } + + const canCreateNew = Boolean( + searchText.trim() && + !filteredOptions.some( + (option) => + option.name.toLowerCase() === searchText.toLowerCase().trim(), + ) && + !selectedPlaylists.some((p) => p.name === searchText.trim()), + ) return ( - <Autocomplete - multiple - disableCloseOnSelect - onChange={handleOnChange} - filterOptions={(options, params) => { - const filtered = filter(options, params) + <div className={classes.root}> + <PlaylistSearchField + searchText={searchText} + onSearchChange={setSearchText} + onCreateNew={handleCreateNew} + onKeyDown={handleKeyDown} + canCreateNew={canCreateNew} + /> - // Suggest the creation of a new value - if (params.inputValue !== '') { - filtered.push({ - inputValue: params.inputValue, - name: translate('resources.playlist.actions.addNewPlaylist', { - name: params.inputValue, - }), - }) - } + <PlaylistList + filteredOptions={filteredOptions} + selectedPlaylists={selectedPlaylists} + onPlaylistToggle={handlePlaylistToggle} + searchText={searchText} + canCreateNew={canCreateNew} + onCreateNew={handleCreateNew} + /> - return filtered - }} - clearOnBlur - handleHomeEndKeys - openOnFocus - selectOnFocus - id="select-playlist-input" - options={options} - getOptionLabel={(option) => { - // Value selected with enter, right from the input - if (typeof option === 'string') { - return option - } - // Add "xxx" option created dynamically - if (option.inputValue) { - return option.inputValue - } - // Regular option - return option.name - }} - renderOption={(option, { selected }) => ( - <React.Fragment> - <Checkbox - icon={icon} - checkedIcon={checkedIcon} - className={classes.checkbox} - checked={selected} - /> - {option.name} - </React.Fragment> - )} - className={classes.root} - freeSolo - renderInput={(params) => ( - <TextField - autoFocus - variant={'outlined'} - {...params} - label={translate('resources.playlist.fields.name')} - /> - )} - /> + <SelectedPlaylistsDisplay + selectedPlaylists={selectedPlaylists} + onRemoveSelected={handleRemoveSelected} + /> + </div> ) } SelectPlaylistInput.propTypes = { onChange: PropTypes.func.isRequired, } + +// PropTypes for sub-components +PlaylistSearchField.propTypes = { + searchText: PropTypes.string.isRequired, + onSearchChange: PropTypes.func.isRequired, + onCreateNew: PropTypes.func.isRequired, + onKeyDown: PropTypes.func.isRequired, + canCreateNew: PropTypes.bool.isRequired, +} + +EmptyPlaylistMessage.propTypes = { + searchText: PropTypes.string.isRequired, + canCreateNew: PropTypes.bool.isRequired, +} + +PlaylistListItem.propTypes = { + playlist: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, +} + +CreatePlaylistItem.propTypes = { + searchText: PropTypes.string.isRequired, + onCreateNew: PropTypes.func.isRequired, +} + +PlaylistList.propTypes = { + filteredOptions: PropTypes.array.isRequired, + selectedPlaylists: PropTypes.array.isRequired, + onPlaylistToggle: PropTypes.func.isRequired, + searchText: PropTypes.string.isRequired, + canCreateNew: PropTypes.bool.isRequired, + onCreateNew: PropTypes.func.isRequired, +} + +SelectedPlaylistChip.propTypes = { + playlist: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, +} + +SelectedPlaylistsDisplay.propTypes = { + selectedPlaylists: PropTypes.array.isRequired, + onRemoveSelected: PropTypes.func.isRequired, +} diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 727126965..93a14b325 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -11,115 +11,483 @@ import { import { SelectPlaylistInput } from './SelectPlaylistInput' import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest' -describe('SelectPlaylistInput', () => { - beforeAll(() => localStorage.setItem('userId', 'admin')) - afterEach(cleanup) - const onChangeHandler = vi.fn() +const mockPlaylists = [ + { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, + { id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' }, + { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin +] - it('should call the handler with the selections', async () => { - const mockData = [ - { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, - { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, - ] - const mockIndexedData = { - 'sample-id1': { - id: 'sample-id1', - name: 'sample playlist 1', - ownerId: 'admin', - }, - 'sample-id2': { - id: 'sample-id2', - name: 'sample playlist 2', - ownerId: 'admin', - }, - } +const mockIndexedData = { + 'playlist-1': { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + 'playlist-2': { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, + 'playlist-3': { + id: 'playlist-3', + name: 'Electronic Beats', + ownerId: 'admin', + }, + 'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, +} - const mockDataProvider = { - getList: vi - .fn() - .mockResolvedValue({ data: mockData, total: mockData.length }), - } +const createTestComponent = ( + mockDataProvider = null, + onChangeMock = vi.fn(), + playlists = mockPlaylists, + indexedData = mockIndexedData, +) => { + const dataProvider = mockDataProvider || { + getList: vi.fn().mockResolvedValue({ + data: playlists, + total: playlists.length, + }), + } - render( - <DataProviderContext.Provider value={mockDataProvider}> - <TestContext - initialState={{ - addToPlaylistDialog: { open: true, duplicateSong: false }, - admin: { - ui: { optimistic: false }, - resources: { - playlist: { - data: mockIndexedData, - list: { - cachedRequests: { - '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}': - { - ids: ['sample-id1', 'sample-id2'], - total: 2, - }, - }, + return render( + <DataProviderContext.Provider value={dataProvider}> + <TestContext + initialState={{ + admin: { + ui: { optimistic: false }, + resources: { + playlist: { + data: indexedData, + list: { + cachedRequests: { + '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}': + { + ids: Object.keys(indexedData), + total: Object.keys(indexedData).length, + }, }, }, }, }, - }} - > - <SelectPlaylistInput onChange={onChangeHandler} /> - </TestContext> - </DataProviderContext.Provider>, - ) + }, + }} + > + <SelectPlaylistInput onChange={onChangeMock} /> + </TestContext> + </DataProviderContext.Provider>, + ) +} - await waitFor(() => { - expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', { - filter: { smart: false }, - pagination: { page: 1, perPage: -1 }, - sort: { field: 'name', order: 'ASC' }, +describe('SelectPlaylistInput', () => { + beforeAll(() => localStorage.setItem('userId', 'admin')) + afterEach(cleanup) + + describe('Basic Functionality', () => { + it('should render search field and playlist list', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + expect(screen.getByText('Jazz Collection')).toBeInTheDocument() + expect(screen.getByText('Electronic Beats')).toBeInTheDocument() + }) + + // Should not show playlists not owned by admin (not writable) + expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument() + }) + + it('should filter playlists based on search input', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'rock' } }) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + expect(screen.queryByText('Jazz Collection')).not.toBeInTheDocument() + expect(screen.queryByText('Electronic Beats')).not.toBeInTheDocument() }) }) - let textBox = screen.getByRole('textbox') - fireEvent.change(textBox, { target: { value: 'sample' } }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'Enter' }) - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalledWith([ - { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, - ]) + it('should handle case-insensitive search', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Jazz Collection')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'JAZZ' } }) + + await waitFor(() => { + expect(screen.getByText('Jazz Collection')).toBeInTheDocument() + expect(screen.queryByText('Rock Classics')).not.toBeInTheDocument() + }) + }) + }) + + describe('Playlist Selection', () => { + it('should select and deselect playlists by clicking', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + // Select first playlist + const rockPlaylist = screen.getByText('Rock Classics') + fireEvent.click(rockPlaylist) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + ]) + }) + + // Select second playlist + const jazzPlaylist = screen.getByText('Jazz Collection') + fireEvent.click(jazzPlaylist) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, + ]) + }) + + // Deselect first playlist + fireEvent.click(rockPlaylist) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, + ]) + }) }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'Enter' }) - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalledWith([ - { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, - { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, - ]) + it('should show selected playlists as chips', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + // Select a playlist + const rockPlaylist = screen.getByText('Rock Classics') + fireEvent.click(rockPlaylist) + + await waitFor(() => { + // Should show the selected playlist as a chip + const chips = screen.getAllByText('Rock Classics') + expect(chips.length).toBeGreaterThan(1) // One in list, one in chip + }) }) - fireEvent.change(textBox, { - target: { value: 'new playlist' }, + it('should remove selected playlists via chip remove button', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + // Select a playlist + const rockPlaylist = screen.getByText('Rock Classics') + fireEvent.click(rockPlaylist) + + await waitFor(() => { + // Should show selected playlist as chip + const chips = screen.getAllByText('Rock Classics') + expect(chips.length).toBeGreaterThan(1) + }) + + // Find and click the remove button (translation key) + const removeButton = screen.getByText( + 'resources.playlist.actions.removeSymbol', + ) + fireEvent.click(removeButton) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([]) + // Should only have one instance (in the list) after removal + const remainingChips = screen.getAllByText('Rock Classics') + expect(remainingChips.length).toBe(1) + }) }) - fireEvent.keyDown(textBox, { key: 'ArrowDown' }) - fireEvent.keyDown(textBox, { key: 'Enter' }) - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalledWith([ - { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, - { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, - { name: 'new playlist' }, - ]) + }) + + describe('Create New Playlist', () => { + it('should create new playlist by pressing Enter', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'My New Playlist' } }) + fireEvent.keyDown(searchInput, { key: 'Enter' }) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My New Playlist' }]) + }) + + // Input should be cleared after creating + expect(searchInput.value).toBe('') }) - fireEvent.change(textBox, { - target: { value: 'another new playlist' }, + it('should create new playlist by clicking add button', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'Another Playlist' } }) + + // Find the add button by the translation key title + const addButton = screen.getByTitle( + 'resources.playlist.actions.addNewPlaylist', + ) + fireEvent.click(addButton) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { name: 'Another Playlist' }, + ]) + }) }) - fireEvent.keyDown(textBox, { key: 'Enter' }) - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalledWith([ - { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, - { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, - { name: 'new playlist' }, - { name: 'another new playlist' }, - ]) + + it('should not show create option for existing playlist names', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'Rock Classics' } }) + + await waitFor(() => { + expect( + screen.queryByText('resources.playlist.actions.addNewPlaylist'), + ).not.toBeInTheDocument() + }) + }) + + it('should not create playlist with empty name', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: ' ' } }) // Only spaces + fireEvent.keyDown(searchInput, { key: 'Enter' }) + + // Should not call onChange + expect(onChangeMock).not.toHaveBeenCalled() + }) + + it('should show create options in appropriate contexts', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + + // When typing a new name, should show create options + fireEvent.change(searchInput, { target: { value: 'My New Playlist' } }) + + await waitFor(() => { + // Should show the add button in the search field + expect( + screen.getByTitle('resources.playlist.actions.addNewPlaylist'), + ).toBeInTheDocument() + // Should also show hint in empty message when no matches + expect( + screen.getByText('resources.playlist.actions.pressEnterToCreate'), + ).toBeInTheDocument() + }) + }) + }) + + describe('Mixed Operations', () => { + it('should handle selecting existing playlists and creating new ones', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + // Select existing playlist + const rockPlaylist = screen.getByText('Rock Classics') + fireEvent.click(rockPlaylist) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + ]) + }) + + // Create new playlist + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'New Mix' } }) + fireEvent.keyDown(searchInput, { key: 'Enter' }) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + { name: 'New Mix' }, + ]) + }) + }) + + it('should maintain selections when searching', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + // Select a playlist + const rockPlaylist = screen.getByText('Rock Classics') + fireEvent.click(rockPlaylist) + + // Filter the list + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'jazz' } }) + + await waitFor(() => { + // Should still show selected playlists section + // Rock Classics should still be visible as a selected chip even though filtered out + expect(screen.getByText('Rock Classics')).toBeInTheDocument() // In selected chips + expect(screen.getByText('Jazz Collection')).toBeInTheDocument() + }) + }) + }) + + describe('Empty States', () => { + it('should show empty message when no playlists exist', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock, [], {}) + + await waitFor(() => { + expect( + screen.getByText('resources.playlist.message.noPlaylists'), + ).toBeInTheDocument() + }) + }) + + it('should show "no results" message when search returns no matches', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { + target: { value: 'NonExistentPlaylist' }, + }) + + await waitFor(() => { + expect( + screen.getByText('resources.playlist.message.noPlaylistsFound'), + ).toBeInTheDocument() + expect( + screen.getByText('resources.playlist.actions.pressEnterToCreate'), + ).toBeInTheDocument() + }) + }) + }) + + describe('Keyboard Navigation', () => { + it('should not create playlist on Enter if input is empty', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.keyDown(searchInput, { key: 'Enter' }) + + expect(onChangeMock).not.toHaveBeenCalled() + }) + + it('should handle other keys without side effects', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'test' } }) + fireEvent.keyDown(searchInput, { key: 'ArrowDown' }) + fireEvent.keyDown(searchInput, { key: 'Tab' }) + fireEvent.keyDown(searchInput, { key: 'Escape' }) + + // Should not create playlist or trigger onChange + expect(onChangeMock).not.toHaveBeenCalled() + expect(searchInput.value).toBe('test') + }) + }) + + describe('Integration Scenarios', () => { + it('should handle complex workflow: search, select, create, remove', async () => { + const onChangeMock = vi.fn() + createTestComponent(null, onChangeMock) + + await waitFor(() => { + expect(screen.getByText('Rock Classics')).toBeInTheDocument() + }) + + // Search and select existing playlist + const searchInput = screen.getByRole('textbox') + fireEvent.change(searchInput, { target: { value: 'rock' } }) + + const rockPlaylist = screen.getByText('Rock Classics') + fireEvent.click(rockPlaylist) + + // Clear search and create new playlist + fireEvent.change(searchInput, { target: { value: 'My Custom Mix' } }) + fireEvent.keyDown(searchInput, { key: 'Enter' }) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([ + { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, + { name: 'My Custom Mix' }, + ]) + }) + + // Remove the first selected playlist via chip + const removeButtons = screen.getAllByText( + 'resources.playlist.actions.removeSymbol', + ) + fireEvent.click(removeButtons[0]) + + await waitFor(() => { + expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My Custom Mix' }]) + }) }) }) }) diff --git a/ui/src/dialogs/aboutUtils.js b/ui/src/dialogs/aboutUtils.js new file mode 100644 index 000000000..7c92692de --- /dev/null +++ b/ui/src/dialogs/aboutUtils.js @@ -0,0 +1,278 @@ +/** + * TOML utility functions for configuration export + */ + +/** + * Flattens nested configuration object and generates environment variable names + * @param {Object} config - The nested configuration object from the backend + * @param {string} prefix - The current prefix for nested keys + * @returns {Array} - Array of config objects with key, envVar, and value properties + */ +export const flattenConfig = (config, prefix = '') => { + const result = [] + + if (!config || typeof config !== 'object') { + return result + } + + Object.keys(config).forEach((key) => { + const value = config[key] + const currentKey = prefix ? `${prefix}.${key}` : key + + if (value && typeof value === 'object' && !Array.isArray(value)) { + // Recursively flatten nested objects + result.push(...flattenConfig(value, currentKey)) + } else { + // Generate environment variable name: ND_ + uppercase with dots replaced by underscores + const envVar = 'ND_' + currentKey.toUpperCase().replace(/\./g, '_') + + // Convert value to string for display + let displayValue = value + if ( + Array.isArray(value) || + (typeof value === 'object' && value !== null) + ) { + displayValue = JSON.stringify(value) + } else { + displayValue = String(value) + } + + result.push({ + key: currentKey, + envVar: envVar, + value: displayValue, + }) + } + }) + + return result +} + +/** + * Separates and sorts configuration entries into regular and dev configs + * @param {Array|Object} configEntries - Array of config objects with key and value, or nested config object + * @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted + */ +export const separateAndSortConfigs = (configEntries) => { + const regularConfigs = [] + const devConfigs = [] + + // Handle both the old array format and new nested object format + let flattenedConfigs + if (Array.isArray(configEntries)) { + // Old format - already flattened + flattenedConfigs = configEntries + } else { + // New format - need to flatten + flattenedConfigs = flattenConfig(configEntries) + } + + flattenedConfigs?.forEach((config) => { + // Skip configFile as it's displayed separately + if (config.key === 'ConfigFile') { + return + } + + if (config.key.startsWith('Dev')) { + devConfigs.push(config) + } else { + regularConfigs.push(config) + } + }) + + // Sort configurations alphabetically + regularConfigs.sort((a, b) => a.key.localeCompare(b.key)) + devConfigs.sort((a, b) => a.key.localeCompare(b.key)) + + return { regularConfigs, devConfigs } +} + +/** + * Escapes TOML keys that contain special characters + * @param {string} key - The key to potentially escape + * @returns {string} - The escaped key if needed, or the original key + */ +export const escapeTomlKey = (key) => { + // Convert to string first to handle null/undefined + const keyStr = String(key) + + // Empty strings always need quotes + if (keyStr === '') { + return '""' + } + + // TOML bare keys can only contain letters, numbers, underscores, and hyphens + // If the key contains other characters, it needs to be quoted + if (/^[a-zA-Z0-9_-]+$/.test(keyStr)) { + return keyStr + } + + // Escape quotes in the key and wrap in quotes + return `"${keyStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +/** + * Converts a value to proper TOML format + * @param {*} value - The value to format + * @returns {string} - The TOML-formatted value + */ +export const formatTomlValue = (value) => { + if (value === null || value === undefined) { + return '""' + } + + const str = String(value) + + // Boolean values + if (str === 'true' || str === 'false') { + return str + } + + // Numbers (integers and floats) + if (/^-?\d+$/.test(str)) { + return str // Integer + } + if (/^-?\d*\.\d+$/.test(str)) { + return str // Float + } + + // Duration values (like "300ms", "1s", "5m") + if (/^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)$/.test(str)) { + return `"${str}"` + } + + // Handle arrays and objects + if (str.startsWith('[') || str.startsWith('{')) { + try { + const parsed = JSON.parse(str) + + // If it's an array, format as TOML array + if (Array.isArray(parsed)) { + const formattedItems = parsed.map((item) => { + if (typeof item === 'string') { + return `"${item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + } else if (typeof item === 'number' || typeof item === 'boolean') { + return String(item) + } else { + return `"${String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + } + }) + + if (formattedItems.length === 0) { + return '[ ]' + } + return `[ ${formattedItems.join(', ')} ]` + } + + // For objects, keep the JSON string format with triple quotes + return `"""${str}"""` + } catch { + return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + } + } + + // String values (escape backslashes and quotes) + return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +/** + * Converts nested keys to TOML sections + * @param {Array} configs - Array of config objects with key and value + * @returns {Object} - Object with sections and rootKeys + */ +export const buildTomlSections = (configs) => { + const sections = {} + const rootKeys = [] + + configs.forEach(({ key, value }) => { + if (key.includes('.')) { + const parts = key.split('.') + const sectionName = parts[0] + const keyName = parts.slice(1).join('.') + + if (!sections[sectionName]) { + sections[sectionName] = [] + } + sections[sectionName].push({ key: keyName, value }) + } else { + rootKeys.push({ key, value }) + } + }) + + return { sections, rootKeys } +} + +/** + * Converts configuration data to TOML format + * @param {Object} configData - The configuration data object + * @param {Function} translate - Translation function for internationalization + * @returns {string} - The TOML-formatted configuration + */ +export const configToToml = (configData, translate = (key) => key) => { + let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n` + + // Handle both old array format (configData.config is array) and new nested format (configData.config is object) + let configs + if (Array.isArray(configData.config)) { + // Old format - already flattened + configs = configData.config + } else { + // New format - need to flatten + configs = flattenConfig(configData.config) + } + + const { regularConfigs, devConfigs } = separateAndSortConfigs(configs) + + // Process regular configs + const { sections: regularSections, rootKeys: regularRootKeys } = + buildTomlSections(regularConfigs) + + // Add root-level keys first + if (regularRootKeys.length > 0) { + regularRootKeys.forEach(({ key, value }) => { + tomlContent += `${key} = ${formatTomlValue(value)}\n` + }) + tomlContent += '\n' + } + + // Add dev configs if any + if (devConfigs.length > 0) { + tomlContent += `# ${translate('about.config.devFlagsHeader')}\n` + tomlContent += `# ${translate('about.config.devFlagsComment')}\n\n` + + const { sections: devSections, rootKeys: devRootKeys } = + buildTomlSections(devConfigs) + + // Add dev root-level keys + devRootKeys.forEach(({ key, value }) => { + tomlContent += `${key} = ${formatTomlValue(value)}\n` + }) + if (devRootKeys.length > 0) { + tomlContent += '\n' + } + + // Add dev sections + Object.keys(devSections) + .sort() + .forEach((sectionName) => { + tomlContent += `[${sectionName}]\n` + devSections[sectionName].forEach(({ key, value }) => { + tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n` + }) + tomlContent += '\n' + }) + } + + // Add sections + Object.keys(regularSections) + .sort() + .forEach((sectionName) => { + tomlContent += `[${sectionName}]\n` + regularSections[sectionName].forEach(({ key, value }) => { + tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n` + }) + tomlContent += '\n' + }) + + return tomlContent +} diff --git a/ui/src/dialogs/aboutUtils.test.js b/ui/src/dialogs/aboutUtils.test.js new file mode 100644 index 000000000..4632c5aed --- /dev/null +++ b/ui/src/dialogs/aboutUtils.test.js @@ -0,0 +1,737 @@ +import { describe, it, expect } from 'vitest' +import { + formatTomlValue, + buildTomlSections, + configToToml, + separateAndSortConfigs, + flattenConfig, + escapeTomlKey, +} from './aboutUtils' + +describe('formatTomlValue', () => { + it('handles null and undefined values', () => { + expect(formatTomlValue(null)).toBe('""') + expect(formatTomlValue(undefined)).toBe('""') + }) + + it('handles boolean values', () => { + expect(formatTomlValue('true')).toBe('true') + expect(formatTomlValue('false')).toBe('false') + expect(formatTomlValue(true)).toBe('true') + expect(formatTomlValue(false)).toBe('false') + }) + + it('handles integer values', () => { + expect(formatTomlValue('123')).toBe('123') + expect(formatTomlValue('-456')).toBe('-456') + expect(formatTomlValue('0')).toBe('0') + expect(formatTomlValue(789)).toBe('789') + }) + + it('handles float values', () => { + expect(formatTomlValue('123.45')).toBe('123.45') + expect(formatTomlValue('-67.89')).toBe('-67.89') + expect(formatTomlValue('0.0')).toBe('0.0') + expect(formatTomlValue(12.34)).toBe('12.34') + }) + + it('handles duration values', () => { + expect(formatTomlValue('300ms')).toBe('"300ms"') + expect(formatTomlValue('5s')).toBe('"5s"') + expect(formatTomlValue('10m')).toBe('"10m"') + expect(formatTomlValue('2h')).toBe('"2h"') + expect(formatTomlValue('1.5s')).toBe('"1.5s"') + }) + + it('handles JSON arrays and objects', () => { + expect(formatTomlValue('["item1", "item2"]')).toBe('[ "item1", "item2" ]') + expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""') + }) + + it('formats different types of arrays correctly', () => { + // String array + expect(formatTomlValue('["genre", "tcon", "©gen"]')).toBe( + '[ "genre", "tcon", "©gen" ]', + ) + // Mixed array with numbers and strings + expect(formatTomlValue('[42, "test", true]')).toBe('[ 42, "test", true ]') + // Empty array + expect(formatTomlValue('[]')).toBe('[ ]') + // Array with special characters in strings + expect( + formatTomlValue('["item with spaces", "item\\"with\\"quotes"]'), + ).toBe('[ "item with spaces", "item\\"with\\"quotes" ]') + }) + + it('handles invalid JSON as regular strings', () => { + expect(formatTomlValue('[invalid json')).toBe('"[invalid json"') + expect(formatTomlValue('{broken')).toBe('"{broken"') + }) + + it('handles regular strings with quote escaping', () => { + expect(formatTomlValue('simple string')).toBe('"simple string"') + expect(formatTomlValue('string with "quotes"')).toBe( + '"string with \\"quotes\\""', + ) + expect(formatTomlValue('/path/to/file')).toBe('"/path/to/file"') + }) + + it('handles strings with backslashes and quotes', () => { + expect(formatTomlValue('C:\\Program Files\\app')).toBe( + '"C:\\\\Program Files\\\\app"', + ) + expect(formatTomlValue('path\\to"file')).toBe('"path\\\\to\\"file"') + expect(formatTomlValue('backslash\\ and "quote"')).toBe( + '"backslash\\\\ and \\"quote\\""', + ) + expect(formatTomlValue('single\\backslash')).toBe('"single\\\\backslash"') + }) + + it('handles empty strings', () => { + expect(formatTomlValue('')).toBe('""') + }) +}) + +describe('buildTomlSections', () => { + it('separates root keys from nested keys', () => { + const configs = [ + { key: 'RootKey1', value: 'value1' }, + { key: 'Section.NestedKey', value: 'value2' }, + { key: 'RootKey2', value: 'value3' }, + { key: 'Section.AnotherKey', value: 'value4' }, + { key: 'AnotherSection.Key', value: 'value5' }, + ] + + const result = buildTomlSections(configs) + + expect(result.rootKeys).toEqual([ + { key: 'RootKey1', value: 'value1' }, + { key: 'RootKey2', value: 'value3' }, + ]) + + expect(result.sections).toEqual({ + Section: [ + { key: 'NestedKey', value: 'value2' }, + { key: 'AnotherKey', value: 'value4' }, + ], + AnotherSection: [{ key: 'Key', value: 'value5' }], + }) + }) + + it('handles deeply nested keys', () => { + const configs = [{ key: 'Section.SubSection.DeepKey', value: 'deepValue' }] + + const result = buildTomlSections(configs) + + expect(result.rootKeys).toEqual([]) + expect(result.sections).toEqual({ + Section: [{ key: 'SubSection.DeepKey', value: 'deepValue' }], + }) + }) + + it('handles empty input', () => { + const result = buildTomlSections([]) + + expect(result.rootKeys).toEqual([]) + expect(result.sections).toEqual({}) + }) +}) + +describe('configToToml', () => { + const mockTranslate = (key) => { + const translations = { + 'about.config.devFlagsHeader': + 'Development Flags (subject to change/removal)', + 'about.config.devFlagsComment': + 'These are experimental settings and may be removed in future versions', + } + return translations[key] || key + } + + it('generates TOML with header and timestamp', () => { + const configData = { + config: [{ key: 'TestKey', value: 'testValue' }], + } + + const result = configToToml(configData, mockTranslate) + + expect(result).toContain('# Navidrome Configuration') + expect(result).toContain('# Generated on') + expect(result).toContain('TestKey = "testValue"') + }) + + it('separates and sorts regular and dev configs', () => { + const configData = { + config: [ + { key: 'ZRegularKey', value: 'regularValue' }, + { key: 'DevTestFlag', value: 'true' }, + { key: 'ARegularKey', value: 'anotherValue' }, + { key: 'DevAnotherFlag', value: 'false' }, + ], + } + + const result = configToToml(configData, mockTranslate) + + // Check that regular configs come first and are sorted + const lines = result.split('\n') + const aRegularIndex = lines.findIndex((line) => + line.includes('ARegularKey'), + ) + const zRegularIndex = lines.findIndex((line) => + line.includes('ZRegularKey'), + ) + const devHeaderIndex = lines.findIndex((line) => + line.includes('Development Flags'), + ) + const devAnotherIndex = lines.findIndex((line) => + line.includes('DevAnotherFlag'), + ) + const devTestIndex = lines.findIndex((line) => line.includes('DevTestFlag')) + + expect(aRegularIndex).toBeLessThan(zRegularIndex) + expect(zRegularIndex).toBeLessThan(devHeaderIndex) + expect(devHeaderIndex).toBeLessThan(devAnotherIndex) + expect(devAnotherIndex).toBeLessThan(devTestIndex) + }) + + it('skips ConfigFile entries', () => { + const configData = { + config: [ + { key: 'ConfigFile', value: '/path/to/config.toml' }, + { key: 'TestKey', value: 'testValue' }, + ], + } + + const result = configToToml(configData, mockTranslate) + + expect(result).not.toContain('ConfigFile =') + expect(result).toContain('TestKey = "testValue"') + }) + + it('handles sections correctly', () => { + const configData = { + config: [ + { key: 'RootKey', value: 'rootValue' }, + { key: 'Section.NestedKey', value: 'nestedValue' }, + { key: 'Section.AnotherKey', value: 'anotherValue' }, + { key: 'DevA', value: 'DevValue' }, + ], + } + + const result = configToToml(configData, mockTranslate) + // Fields in a section are sorted alphabetically + const fields = [ + 'RootKey = "rootValue"', + 'DevA = "DevValue"', + '[Section]', + 'AnotherKey = "anotherValue"', + 'NestedKey = "nestedValue"', + ] + + for (let idx = 0; idx < fields.length - 1; idx++) { + expect(result).toContain(fields[idx]) + + const idxA = result.indexOf(fields[idx]) + const idxB = result.indexOf(fields[idx + 1]) + + expect(idxA).toBeLessThan(idxB) + } + + expect(result).toContain(fields[fields.length - 1]) + }) + + it('includes dev flags header when dev configs exist', () => { + const configData = { + config: [ + { key: 'RegularKey', value: 'regularValue' }, + { key: 'DevTestFlag', value: 'true' }, + ], + } + + const result = configToToml(configData, mockTranslate) + + expect(result).toContain('# Development Flags (subject to change/removal)') + expect(result).toContain( + '# These are experimental settings and may be removed in future versions', + ) + expect(result).toContain('DevTestFlag = true') + }) + + it('does not include dev flags header when no dev configs exist', () => { + const configData = { + config: [{ key: 'RegularKey', value: 'regularValue' }], + } + + const result = configToToml(configData, mockTranslate) + + expect(result).not.toContain('Development Flags') + expect(result).toContain('RegularKey = "regularValue"') + }) + + it('handles empty config data', () => { + const configData = { config: [] } + + const result = configToToml(configData, mockTranslate) + + expect(result).toContain('# Navidrome Configuration') + expect(result).not.toContain('Development Flags') + }) + + it('handles missing config array', () => { + const configData = {} + + const result = configToToml(configData, mockTranslate) + + expect(result).toContain('# Navidrome Configuration') + expect(result).not.toContain('Development Flags') + }) + + it('works without translate function', () => { + const configData = { + config: [{ key: 'DevTestFlag', value: 'true' }], + } + + const result = configToToml(configData) + + expect(result).toContain('# about.config.devFlagsHeader') + expect(result).toContain('# about.config.devFlagsComment') + expect(result).toContain('DevTestFlag = true') + }) + + it('handles various data types correctly', () => { + const configData = { + config: [ + { key: 'StringValue', value: 'test string' }, + { key: 'BooleanValue', value: 'true' }, + { key: 'IntegerValue', value: '42' }, + { key: 'FloatValue', value: '3.14' }, + { key: 'DurationValue', value: '5s' }, + { key: 'ArrayValue', value: '["item1", "item2"]' }, + ], + } + + const result = configToToml(configData, mockTranslate) + + expect(result).toContain('StringValue = "test string"') + expect(result).toContain('BooleanValue = true') + expect(result).toContain('IntegerValue = 42') + expect(result).toContain('FloatValue = 3.14') + expect(result).toContain('DurationValue = "5s"') + expect(result).toContain('ArrayValue = [ "item1", "item2" ]') + }) + + it('handles nested config object format correctly', () => { + const configData = { + config: { + Address: '127.0.0.1', + Port: 4533, + EnableDownloads: true, + DevLogSourceLine: false, + LastFM: { + Enabled: true, + ApiKey: 'secret123', + Language: 'en', + }, + Scanner: { + Schedule: 'daily', + Enabled: true, + }, + }, + } + + const result = configToToml(configData, mockTranslate) + + // Should contain regular configs + expect(result).toContain('Address = "127.0.0.1"') + expect(result).toContain('Port = 4533') + expect(result).toContain('EnableDownloads = true') + + // Should contain dev configs with header + expect(result).toContain('# Development Flags (subject to change/removal)') + expect(result).toContain('DevLogSourceLine = false') + + // Should contain sections + expect(result).toContain('[LastFM]') + expect(result).toContain('Enabled = true') + expect(result).toContain('ApiKey = "secret123"') + expect(result).toContain('Language = "en"') + + expect(result).toContain('[Scanner]') + expect(result).toContain('Schedule = "daily"') + }) + + it('handles mixed nested and flat structure', () => { + const configData = { + config: { + MusicFolder: '/music', + DevAutoLoginUsername: 'testuser', + Jukebox: { + Enabled: false, + AdminOnly: true, + }, + }, + } + + const result = configToToml(configData, mockTranslate) + + expect(result).toContain('MusicFolder = "/music"') + expect(result).toContain('DevAutoLoginUsername = "testuser"') + expect(result).toContain('[Jukebox]') + expect(result).toContain('Enabled = false') + expect(result).toContain('AdminOnly = true') + }) + + it('properly escapes keys with special characters in sections', () => { + const configData = { + config: [ + { key: 'DevLogLevels.persistence/sql_base_repository', value: 'trace' }, + { key: 'DevLogLevels.core/scanner', value: 'debug' }, + { key: 'DevLogLevels.regular_key', value: 'info' }, + { key: 'Tags.genre.Aliases', value: '["tcon","genre","©gen"]' }, + ], + } + + const result = configToToml(configData, mockTranslate) + + // Keys with forward slashes should be quoted + expect(result).toContain('"persistence/sql_base_repository" = "trace"') + expect(result).toContain('"core/scanner" = "debug"') + + // Regular keys should not be quoted + expect(result).toContain('regular_key = "info"') + + // Arrays should be formatted correctly + expect(result).toContain('"genre.Aliases" = [ "tcon", "genre", "©gen" ]') + + // Should contain proper sections + expect(result).toContain('[DevLogLevels]') + expect(result).toContain('[Tags]') + }) +}) + +describe('flattenConfig', () => { + it('flattens simple nested objects correctly', () => { + const config = { + Address: '0.0.0.0', + Port: 4533, + EnableDownloads: true, + LastFM: { + Enabled: true, + ApiKey: 'secret123', + Language: 'en', + }, + } + + const result = flattenConfig(config) + + expect(result).toContainEqual({ + key: 'Address', + envVar: 'ND_ADDRESS', + value: '0.0.0.0', + }) + + expect(result).toContainEqual({ + key: 'Port', + envVar: 'ND_PORT', + value: '4533', + }) + + expect(result).toContainEqual({ + key: 'EnableDownloads', + envVar: 'ND_ENABLEDOWNLOADS', + value: 'true', + }) + + expect(result).toContainEqual({ + key: 'LastFM.Enabled', + envVar: 'ND_LASTFM_ENABLED', + value: 'true', + }) + + expect(result).toContainEqual({ + key: 'LastFM.ApiKey', + envVar: 'ND_LASTFM_APIKEY', + value: 'secret123', + }) + + expect(result).toContainEqual({ + key: 'LastFM.Language', + envVar: 'ND_LASTFM_LANGUAGE', + value: 'en', + }) + }) + + it('handles deeply nested objects', () => { + const config = { + Scanner: { + Schedule: 'daily', + Options: { + ExtractorType: 'taglib', + ArtworkPriority: 'cover.jpg', + }, + }, + } + + const result = flattenConfig(config) + + expect(result).toContainEqual({ + key: 'Scanner.Schedule', + envVar: 'ND_SCANNER_SCHEDULE', + value: 'daily', + }) + + expect(result).toContainEqual({ + key: 'Scanner.Options.ExtractorType', + envVar: 'ND_SCANNER_OPTIONS_EXTRACTORTYPE', + value: 'taglib', + }) + + expect(result).toContainEqual({ + key: 'Scanner.Options.ArtworkPriority', + envVar: 'ND_SCANNER_OPTIONS_ARTWORKPRIORITY', + value: 'cover.jpg', + }) + }) + + it('handles arrays correctly', () => { + const config = { + DeviceList: ['device1', 'device2'], + Settings: { + EnabledFormats: ['mp3', 'flac', 'ogg'], + }, + } + + const result = flattenConfig(config) + + expect(result).toContainEqual({ + key: 'DeviceList', + envVar: 'ND_DEVICELIST', + value: '["device1","device2"]', + }) + + expect(result).toContainEqual({ + key: 'Settings.EnabledFormats', + envVar: 'ND_SETTINGS_ENABLEDFORMATS', + value: '["mp3","flac","ogg"]', + }) + }) + + it('handles null and undefined values', () => { + const config = { + NullValue: null, + UndefinedValue: undefined, + EmptyString: '', + ZeroValue: 0, + } + + const result = flattenConfig(config) + + expect(result).toContainEqual({ + key: 'NullValue', + envVar: 'ND_NULLVALUE', + value: 'null', + }) + + expect(result).toContainEqual({ + key: 'UndefinedValue', + envVar: 'ND_UNDEFINEDVALUE', + value: 'undefined', + }) + + expect(result).toContainEqual({ + key: 'EmptyString', + envVar: 'ND_EMPTYSTRING', + value: '', + }) + + expect(result).toContainEqual({ + key: 'ZeroValue', + envVar: 'ND_ZEROVALUE', + value: '0', + }) + }) + + it('handles empty object', () => { + const result = flattenConfig({}) + expect(result).toEqual([]) + }) + + it('handles null/undefined input', () => { + expect(flattenConfig(null)).toEqual([]) + expect(flattenConfig(undefined)).toEqual([]) + }) + + it('handles non-object input', () => { + expect(flattenConfig('string')).toEqual([]) + expect(flattenConfig(123)).toEqual([]) + expect(flattenConfig(true)).toEqual([]) + }) +}) + +describe('separateAndSortConfigs', () => { + it('separates regular and dev configs correctly with array input', () => { + const configs = [ + { key: 'RegularKey1', value: 'value1' }, + { key: 'DevTestFlag', value: 'true' }, + { key: 'AnotherRegular', value: 'value2' }, + { key: 'DevAnotherFlag', value: 'false' }, + ] + + const result = separateAndSortConfigs(configs) + + expect(result.regularConfigs).toEqual([ + { key: 'AnotherRegular', value: 'value2' }, + { key: 'RegularKey1', value: 'value1' }, + ]) + + expect(result.devConfigs).toEqual([ + { key: 'DevAnotherFlag', value: 'false' }, + { key: 'DevTestFlag', value: 'true' }, + ]) + }) + + it('separates regular and dev configs correctly with nested object input', () => { + const config = { + Address: '127.0.0.1', + Port: 4533, + DevAutoLoginUsername: 'testuser', + DevLogSourceLine: true, + LastFM: { + Enabled: true, + ApiKey: 'secret123', + }, + } + + const result = separateAndSortConfigs(config) + + expect(result.regularConfigs).toEqual([ + { key: 'Address', envVar: 'ND_ADDRESS', value: '127.0.0.1' }, + { key: 'LastFM.ApiKey', envVar: 'ND_LASTFM_APIKEY', value: 'secret123' }, + { key: 'LastFM.Enabled', envVar: 'ND_LASTFM_ENABLED', value: 'true' }, + { key: 'Port', envVar: 'ND_PORT', value: '4533' }, + ]) + + expect(result.devConfigs).toEqual([ + { + key: 'DevAutoLoginUsername', + envVar: 'ND_DEVAUTOLOGINUSERNAME', + value: 'testuser', + }, + { key: 'DevLogSourceLine', envVar: 'ND_DEVLOGSOURCELINE', value: 'true' }, + ]) + }) + + it('skips ConfigFile entries', () => { + const configs = [ + { key: 'ConfigFile', value: '/path/to/config.toml' }, + { key: 'RegularKey', value: 'value' }, + { key: 'DevFlag', value: 'true' }, + ] + + const result = separateAndSortConfigs(configs) + + expect(result.regularConfigs).toEqual([ + { key: 'RegularKey', value: 'value' }, + ]) + expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }]) + }) + + it('skips ConfigFile entries with nested object input', () => { + const config = { + ConfigFile: '/path/to/config.toml', + RegularKey: 'value', + DevFlag: true, + } + + const result = separateAndSortConfigs(config) + + expect(result.regularConfigs).toEqual([ + { key: 'RegularKey', envVar: 'ND_REGULARKEY', value: 'value' }, + ]) + expect(result.devConfigs).toEqual([ + { key: 'DevFlag', envVar: 'ND_DEVFLAG', value: 'true' }, + ]) + }) + + it('handles empty input', () => { + const result = separateAndSortConfigs([]) + + expect(result.regularConfigs).toEqual([]) + expect(result.devConfigs).toEqual([]) + }) + + it('handles null/undefined input', () => { + const result1 = separateAndSortConfigs(null) + const result2 = separateAndSortConfigs(undefined) + + expect(result1.regularConfigs).toEqual([]) + expect(result1.devConfigs).toEqual([]) + expect(result2.regularConfigs).toEqual([]) + expect(result2.devConfigs).toEqual([]) + }) + + it('sorts configs alphabetically', () => { + const configs = [ + { key: 'ZRegular', value: 'z' }, + { key: 'ARegular', value: 'a' }, + { key: 'DevZ', value: 'z' }, + { key: 'DevA', value: 'a' }, + ] + + const result = separateAndSortConfigs(configs) + + expect(result.regularConfigs[0].key).toBe('ARegular') + expect(result.regularConfigs[1].key).toBe('ZRegular') + expect(result.devConfigs[0].key).toBe('DevA') + expect(result.devConfigs[1].key).toBe('DevZ') + }) +}) + +describe('escapeTomlKey', () => { + it('does not escape valid bare keys', () => { + expect(escapeTomlKey('RegularKey')).toBe('RegularKey') + expect(escapeTomlKey('regular_key')).toBe('regular_key') + expect(escapeTomlKey('regular-key')).toBe('regular-key') + expect(escapeTomlKey('key123')).toBe('key123') + expect(escapeTomlKey('Key_with_underscores')).toBe('Key_with_underscores') + expect(escapeTomlKey('Key-with-hyphens')).toBe('Key-with-hyphens') + }) + + it('escapes keys with special characters', () => { + // Keys with forward slashes (like DevLogLevels keys) + expect(escapeTomlKey('persistence/sql_base_repository')).toBe( + '"persistence/sql_base_repository"', + ) + expect(escapeTomlKey('core/scanner')).toBe('"core/scanner"') + + // Keys with dots + expect(escapeTomlKey('Section.NestedKey')).toBe('"Section.NestedKey"') + + // Keys with spaces + expect(escapeTomlKey('key with spaces')).toBe('"key with spaces"') + + // Keys with other special characters + expect(escapeTomlKey('key@with@symbols')).toBe('"key@with@symbols"') + expect(escapeTomlKey('key+with+plus')).toBe('"key+with+plus"') + }) + + it('escapes quotes in keys', () => { + expect(escapeTomlKey('key"with"quotes')).toBe('"key\\"with\\"quotes"') + expect(escapeTomlKey('key with "quotes" inside')).toBe( + '"key with \\"quotes\\" inside"', + ) + }) + + it('escapes backslashes in keys', () => { + expect(escapeTomlKey('key\\with\\backslashes')).toBe( + '"key\\\\with\\\\backslashes"', + ) + expect(escapeTomlKey('path\\to\\file')).toBe('"path\\\\to\\\\file"') + }) + + it('handles empty and null keys', () => { + expect(escapeTomlKey('')).toBe('""') + expect(escapeTomlKey(null)).toBe('null') + expect(escapeTomlKey(undefined)).toBe('undefined') + }) +}) diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 7f71529f8..86586aef0 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -1,4 +1,5 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' +export * from './SaveQueueDialog' export * from './Dialogs' diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index 34463e19c..c91dae875 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -1,7 +1,8 @@ import { baseUrl } from './utils' import throttle from 'lodash.throttle' -import { processEvent, serverDown } from './actions' +import { processEvent, serverDown, streamReconnected } from './actions' import { REST_URL } from './consts' +import config from './config' const newEventStream = async () => { let url = baseUrl(`${REST_URL}/events`) @@ -11,6 +12,51 @@ const newEventStream = async () => { return new EventSource(url) } +let eventStream +let reconnectTimer +const RECONNECT_DELAY = 5000 + +const setupHandlers = (stream, dispatchFn) => { + stream.addEventListener('serverStart', eventHandler(dispatchFn)) + stream.addEventListener('scanStatus', throttledEventHandler(dispatchFn)) + stream.addEventListener('refreshResource', eventHandler(dispatchFn)) + if (config.enableNowPlaying) { + stream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) + } + stream.addEventListener('keepAlive', eventHandler(dispatchFn)) + stream.onerror = (e) => { + // eslint-disable-next-line no-console + console.log('EventStream error', e) + dispatchFn(serverDown()) + if (stream) stream.close() + scheduleReconnect(dispatchFn) + } +} + +const scheduleReconnect = (dispatchFn) => { + if (!reconnectTimer) { + reconnectTimer = setTimeout(() => { + reconnectTimer = null + connect(dispatchFn) + }, RECONNECT_DELAY) + } +} + +const connect = async (dispatchFn) => { + try { + const stream = await newEventStream() + eventStream = stream + setupHandlers(stream, dispatchFn) + // Dispatch reconnection event to refresh critical data + dispatchFn(streamReconnected()) + return stream + } catch (e) { + // eslint-disable-next-line no-console + console.log(`Error connecting to server:`, e) + scheduleReconnect(dispatchFn) + } +} + const eventHandler = (dispatchFn) => (event) => { const data = JSON.parse(event.data) if (event.type !== 'keepAlive') { @@ -21,10 +67,7 @@ const eventHandler = (dispatchFn) => (event) => { const throttledEventHandler = (dispatchFn) => throttle(eventHandler(dispatchFn), 100, { trailing: true }) -const startEventStream = async (dispatchFn) => { - if (!localStorage.getItem('is-authenticated')) { - return Promise.resolve() - } +const startEventStreamLegacy = async (dispatchFn) => { return newEventStream() .then((newStream) => { newStream.addEventListener('serverStart', eventHandler(dispatchFn)) @@ -33,6 +76,9 @@ const startEventStream = async (dispatchFn) => { throttledEventHandler(dispatchFn), ) newStream.addEventListener('refreshResource', eventHandler(dispatchFn)) + if (config.enableNowPlaying) { + newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) + } newStream.addEventListener('keepAlive', eventHandler(dispatchFn)) newStream.onerror = (e) => { // eslint-disable-next-line no-console @@ -47,4 +93,22 @@ const startEventStream = async (dispatchFn) => { }) } +const startEventStreamNew = async (dispatchFn) => { + if (eventStream) { + eventStream.close() + eventStream = null + } + return connect(dispatchFn) +} + +const startEventStream = async (dispatchFn) => { + if (!localStorage.getItem('is-authenticated')) { + return Promise.resolve() + } + if (config.devNewEventStream) { + return startEventStreamNew(dispatchFn) + } + return startEventStreamLegacy(dispatchFn) +} + export { startEventStream } diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js new file mode 100644 index 000000000..77d061c19 --- /dev/null +++ b/ui/src/eventStream.test.js @@ -0,0 +1,49 @@ +import { describe, it, beforeEach, vi, expect } from 'vitest' +import { startEventStream } from './eventStream' +import { serverDown } from './actions' +import config from './config' + +class MockEventSource { + constructor(url) { + this.url = url + this.readyState = 1 + this.listeners = {} + this.onerror = null + } + addEventListener(type, handler) { + this.listeners[type] = handler + } + close() { + this.readyState = 2 + } +} + +describe('startEventStream', () => { + vi.useFakeTimers() + let dispatch + let instance + + beforeEach(() => { + dispatch = vi.fn() + global.EventSource = vi.fn((url) => { + instance = new MockEventSource(url) + return instance + }) + localStorage.setItem('is-authenticated', 'true') + localStorage.setItem('token', 'abc') + config.devNewEventStream = true + }) + + afterEach(() => { + config.devNewEventStream = false + }) + + it('reconnects after an error', async () => { + await startEventStream(dispatch) + expect(global.EventSource).toHaveBeenCalledTimes(1) + instance.onerror(new Event('error')) + expect(dispatch).toHaveBeenCalledWith(serverDown()) + vi.advanceTimersByTime(5000) + expect(global.EventSource).toHaveBeenCalledTimes(2) + }) +}) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 5ccebf115..7bd124ec6 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -34,12 +34,14 @@ "participants": "Additional participants", "tags": "Additional Tags", "mappedTags": "Mapped tags", - "rawTags": "Raw tags" + "rawTags": "Raw tags", + "missing": "Missing" }, "actions": { "addToQueue": "Play Later", "playNow": "Play Now", "addToPlaylist": "Add to Playlist", + "showInPlaylist": "Show in Playlist", "shuffleAll": "Shuffle All", "download": "Download", "playNext": "Play Next", @@ -73,7 +75,8 @@ "releaseType": "Type", "grouping": "Grouping", "media": "Media", - "mood": "Mood" + "mood": "Mood", + "missing": "Missing" }, "actions": { "playAll": "Play", @@ -105,7 +108,8 @@ "playCount": "Plays", "rating": "Rating", "genre": "Genre", - "role": "Role" + "role": "Role", + "missing": "Missing" }, "roles": { "albumartist": "Album Artist |||| Album Artists", @@ -120,7 +124,13 @@ "mixer": "Mixer |||| Mixers", "remixer": "Remixer |||| Remixers", "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Performer |||| Performers" + "performer": "Performer |||| Performers", + "maincredit": "Album Artist or Artist |||| Album Artists or Artists" + }, + "actions": { + "topSongs": "Top Songs", + "shuffle": "Shuffle", + "radio": "Radio" } }, "user": { @@ -192,12 +202,19 @@ "selectPlaylist": "Select a playlist:", "addNewPlaylist": "Create \"%{name}\"", "export": "Export", + "saveQueue": "Save Queue to Playlist", "makePublic": "Make Public", - "makePrivate": "Make Private" + "makePrivate": "Make Private", + "searchOrCreate": "Search playlists or type to create new...", + "pressEnterToCreate": "Press Enter to create new playlist", + "removeFromSelection": "Remove from selection", + "removeSymbol": "×" }, "message": { "duplicate_song": "Add duplicated songs", - "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?" + "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", + "noPlaylistsFound": "No playlists found", + "noPlaylists": "No playlists available" } }, "radio": { @@ -233,7 +250,7 @@ "actions": {} }, "missing": { - "name": "Missing File|||| Missing Files", + "name": "Missing File |||| Missing Files", "empty": "No Missing Files", "fields": { "path": "Path", @@ -241,7 +258,8 @@ "updatedAt": "Disappeared on" }, "actions": { - "remove": "Remove" + "remove": "Remove", + "remove_all": "Remove All" }, "notifications": { "removed": "Missing file(s) removed" @@ -398,11 +416,15 @@ "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", "songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist", + "noSimilarSongsFound": "No similar songs found", + "noTopSongsFound": "No top songs found", "noPlaylistsAvailable": "None available", "delete_user_title": "Delete user '%{name}'", "delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?", "remove_missing_title": "Remove missing files", "remove_missing_content": "Are you sure you want to remove the selected missing files from the database? This will remove permanently any references to them, including their play counts and ratings.", + "remove_all_missing_title": "Remove all missing files", + "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", @@ -491,6 +513,21 @@ "disabled": "Disabled", "waiting": "Waiting" } + }, + "tabs": { + "about": "About", + "config": "Configuration" + }, + "config": { + "configName": "Config Name", + "environmentVariable": "Environment Variable", + "currentValue": "Current Value", + "configurationFile": "Configuration File", + "exportToml": "Export Configuration (TOML)", + "exportSuccess": "Configuration exported to clipboard in TOML format", + "exportFailed": "Failed to copy configuration", + "devFlagsHeader": "Development Flags (subject to change/removal)", + "devFlagsComment": "These are experimental settings and may be removed in future versions" } }, "activity": { @@ -504,6 +541,11 @@ "status": "Scan Error", "elapsedTime": "Elapsed Time" }, + "nowPlaying": { + "title": "Now Playing", + "empty": "Nothing playing", + "minutesAgo": "%{smart_count} minute ago |||| %{smart_count} minutes ago" + }, "help": { "title": "Navidrome Hotkeys", "hotkeys": { diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx index f7371bc2f..6b50cee0c 100644 --- a/ui/src/layout/ActivityPanel.jsx +++ b/ui/src/layout/ActivityPanel.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import { useSelector } from 'react-redux' import { useNotify, useTranslate } from 'react-admin' import { Popover, @@ -15,12 +15,13 @@ import { Typography, } from '@material-ui/core' import { FiActivity } from 'react-icons/fi' -import { BiError, BiCheckCircle } from 'react-icons/bi' +import { BiError } from 'react-icons/bi' import { VscSync } from 'react-icons/vsc' import { GiMagnifyingGlass } from 'react-icons/gi' import subsonic from '../subsonic' -import { scanStatusUpdate } from '../actions' +import { useInitialScanStatus } from './useInitialScanStatus' import { useInterval } from '../common' +import { useScanElapsedTime } from './useScanElapsedTime' import { formatDuration, formatShortDuration } from '../utils' import config from '../config' @@ -70,29 +71,21 @@ const ActivityPanel = () => { const serverStart = useSelector((state) => state.activity.serverStart) const up = serverStart.startTime const scanStatus = useSelector((state) => state.activity.scanStatus) + const elapsed = useScanElapsedTime( + scanStatus.scanning, + scanStatus.elapsedTime, + ) const classes = useStyles({ up: up && !scanStatus.error }) const translate = useTranslate() const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) - const dispatch = useDispatch() + useInitialScanStatus() const handleMenuOpen = (event) => setAnchorEl(event.currentTarget) const handleMenuClose = () => setAnchorEl(null) const triggerScan = (full) => () => subsonic.startScan({ fullScan: full }) - // Get updated status on component mount - useEffect(() => { - subsonic - .getScanStatus() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - dispatch(scanStatusUpdate(data.scanStatus)) - } - }) - }, [dispatch]) - useEffect(() => { if (serverStart.version && serverStart.version !== config.version) { notify('ra.notification.new_version', 'info', {}, false, 604800000 * 50) @@ -178,7 +171,7 @@ const ActivityPanel = () => { {translate('activity.elapsedTime')}: </Box> <Box component="span" flex={1}> - {formatShortDuration(scanStatus.elapsedTime)} + {formatShortDuration(elapsed)} </Box> </Box> diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx index a8c36cd14..561701dce 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.jsx @@ -14,6 +14,7 @@ import { Dialogs } from '../dialogs/Dialogs' import { AboutDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' +import NowPlayingPanel from './NowPlayingPanel' import UserMenu from './UserMenu' import config from '../config' @@ -49,7 +50,7 @@ const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { <> <MenuItem ref={ref} onClick={handleOpen} className={classes.root}> <ListItemIcon className={classes.icon}> - <MdInfo titleAccess={label} size={24} /> + <MdInfo title={label} size={24} /> </ListItemIcon> {label} </MenuItem> @@ -119,6 +120,9 @@ const CustomUserMenu = ({ onClick, ...rest }) => { return ( <> + {config.devActivityPanel && + permissions === 'admin' && + config.enableNowPlaying && <NowPlayingPanel />} {config.devActivityPanel && permissions === 'admin' && <ActivityPanel />} <UserMenu {...rest}> <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx new file mode 100644 index 000000000..f39dd75cb --- /dev/null +++ b/ui/src/layout/AppBar.test.jsx @@ -0,0 +1,65 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { activityReducer } from '../reducers' +import AppBar from './AppBar' +import config from '../config' + +let store + +vi.mock('react-admin', () => ({ + AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>, + useTranslate: () => (x) => x, + usePermissions: () => ({ permissions: 'admin' }), + getResources: () => [], +})) + +vi.mock('./NowPlayingPanel', () => ({ + default: () => <div data-testid="now-playing-panel" />, +})) +vi.mock('./ActivityPanel', () => ({ + default: () => <div data-testid="activity-panel" />, +})) +vi.mock('./PersonalMenu', () => ({ + default: () => <div />, +})) +vi.mock('./UserMenu', () => ({ + default: ({ children }) => <div>{children}</div>, +})) +vi.mock('../dialogs/Dialogs', () => ({ + Dialogs: () => <div />, +})) +vi.mock('../dialogs', () => ({ + AboutDialog: () => <div />, +})) + +describe('<AppBar />', () => { + beforeEach(() => { + config.devActivityPanel = true + config.enableNowPlaying = true + store = createStore(combineReducers({ activity: activityReducer }), { + activity: { nowPlayingCount: 0 }, + }) + }) + + it('renders NowPlayingPanel when enabled', () => { + render( + <Provider store={store}> + <AppBar /> + </Provider>, + ) + expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument() + }) + + it('hides NowPlayingPanel when disabled', () => { + config.enableNowPlaying = false + render( + <Provider store={store}> + <AppBar /> + </Provider>, + ) + expect(screen.queryByTestId('now-playing-panel')).toBeNull() + }) +}) diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx new file mode 100644 index 000000000..4aaee1bee --- /dev/null +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -0,0 +1,353 @@ +import React, { useState, useEffect, useCallback } from 'react' +import PropTypes from 'prop-types' +import { useSelector, useDispatch } from 'react-redux' +import { useTranslate, Link, useNotify } from 'react-admin' +import { + Popover, + IconButton, + makeStyles, + Tooltip, + List, + ListItem, + ListItemText, + ListItemAvatar, + Avatar, + Badge, + Card, + CardContent, + Typography, + useTheme, + useMediaQuery, +} from '@material-ui/core' +import { FaRegCirclePlay } from 'react-icons/fa6' +import subsonic from '../subsonic' +import { useInterval } from '../common' +import { nowPlayingCountUpdate } from '../actions' +import config from '../config' + +const useStyles = makeStyles((theme) => ({ + button: { color: 'inherit' }, + list: { + width: '30em', + maxHeight: (props) => { + // Calculate height for up to 4 entries before scrolling + const entryHeight = 80 + const maxEntries = Math.min(props.entryCount || 0, 4) + return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' + }, + overflowY: 'auto', + padding: 0, + }, + card: { + padding: 0, + }, + cardContent: { + padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + '&:last-child': { + paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + }, + }, + listItem: { + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + avatar: { + width: theme.spacing(6), + height: theme.spacing(6), + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }, + badge: { + '& .MuiBadge-badge': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, + artistLink: { + cursor: 'pointer', + '&:hover': { + textDecoration: 'underline', + }, + }, + primaryText: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + }, +})) + +// NowPlayingButton component - handles the button with badge +const NowPlayingButton = React.memo(({ count, onClick }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <Tooltip title={translate('nowPlaying.title')}> + <IconButton + className={classes.button} + onClick={onClick} + aria-label={translate('nowPlaying.title')} + aria-haspopup="true" + > + <Badge + badgeContent={count} + color="primary" + overlap="rectangular" + className={classes.badge} + > + <FaRegCirclePlay size={20} /> + </Badge> + </IconButton> + </Tooltip> + ) +}) + +NowPlayingButton.displayName = 'NowPlayingButton' + +NowPlayingButton.propTypes = { + count: PropTypes.number.isRequired, + onClick: PropTypes.func.isRequired, +} + +// NowPlayingItem component - individual list item +const NowPlayingItem = React.memo( + ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <ListItem key={nowPlayingEntry.playerId} className={classes.listItem}> + <ListItemAvatar> + <Link + to={`/album/${nowPlayingEntry.albumId}/show`} + onClick={onLinkClick} + > + <Avatar + className={classes.avatar} + src={subsonic.getCoverArtUrl(nowPlayingEntry, 80)} + variant="square" + alt={`${nowPlayingEntry.album} cover art`} + loading="lazy" + /> + </Link> + </ListItemAvatar> + <ListItemText + primary={ + <div className={classes.primaryText}> + {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( + <Link + to={getArtistLink( + nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId, + )} + className={classes.artistLink} + onClick={onLinkClick} + > + {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} + </Link> + ) : ( + <span> + {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} + </span> + )} +  - {nowPlayingEntry.title} + </div> + } + secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} + /> + </ListItem> + ) + }, +) + +NowPlayingItem.displayName = 'NowPlayingItem' + +NowPlayingItem.propTypes = { + nowPlayingEntry: PropTypes.shape({ + playerId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + albumId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, + albumArtistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + artistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + albumArtist: PropTypes.string, + artist: PropTypes.string, + title: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + playerName: PropTypes.string, + minutesAgo: PropTypes.number.isRequired, + album: PropTypes.string, + }).isRequired, + onLinkClick: PropTypes.func.isRequired, + getArtistLink: PropTypes.func.isRequired, +} + +// NowPlayingList component - handles the popover content +const NowPlayingList = React.memo( + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + const classes = useStyles({ entryCount: entries.length }) + const translate = useTranslate() + + return ( + <Popover + id="panel-nowplaying" + anchorEl={anchorEl} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + open={open} + onClose={onClose} + aria-labelledby="now-playing-title" + > + <Card className={classes.card}> + <CardContent className={classes.cardContent}> + {entries.length === 0 ? ( + <Typography id="now-playing-title"> + {translate('nowPlaying.empty')} + </Typography> + ) : ( + <List + className={classes.list} + dense + aria-label={translate('nowPlaying.title')} + > + {entries.map((nowPlayingEntry) => ( + <NowPlayingItem + key={nowPlayingEntry.playerId} + nowPlayingEntry={nowPlayingEntry} + onLinkClick={onLinkClick} + getArtistLink={getArtistLink} + /> + ))} + </List> + )} + </CardContent> + </Card> + </Popover> + ) + }, +) + +NowPlayingList.displayName = 'NowPlayingList' + +NowPlayingList.propTypes = { + anchorEl: PropTypes.object, + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + entries: PropTypes.arrayOf(PropTypes.object).isRequired, + onLinkClick: PropTypes.func.isRequired, + getArtistLink: PropTypes.func.isRequired, +} + +// Main NowPlayingPanel component +const NowPlayingPanel = () => { + const dispatch = useDispatch() + const count = useSelector((state) => state.activity.nowPlayingCount) + const streamReconnected = useSelector( + (state) => state.activity.streamReconnected, + ) + const serverUp = useSelector( + (state) => !!state.activity.serverStart.startTime, + ) + const translate = useTranslate() + const notify = useNotify() + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) + + const [anchorEl, setAnchorEl] = useState(null) + const [entries, setEntries] = useState([]) + const open = Boolean(anchorEl) + + const handleMenuOpen = useCallback((event) => { + setAnchorEl(event.currentTarget) + }, []) + + const handleMenuClose = useCallback(() => { + setAnchorEl(null) + }, []) + + // Close panel when link is clicked on small screens + const handleLinkClick = useCallback(() => { + if (isSmallScreen) { + handleMenuClose() + } + }, [isSmallScreen, handleMenuClose]) + + const getArtistLink = useCallback((artistId) => { + if (!artistId) return null + return config.devShowArtistPage && artistId !== config.variousArtistsId + ? `/artist/${artistId}/show` + : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` + }, []) + + const fetchList = useCallback( + () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + setEntries(nowPlayingEntries) + // Also update the count in Redux store + dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, + }) + }), + [dispatch, notify], + ) + + // Initialize count and entries on mount, and refresh on server/stream changes + useEffect(() => { + if (serverUp) fetchList() + }, [fetchList, serverUp, streamReconnected]) + + // Refresh when count changes from WebSocket events (if panel is open) + useEffect(() => { + if (open && serverUp) fetchList() + }, [count, open, fetchList, serverUp]) + + // Periodic refresh when panel is open (10 seconds) + useInterval( + () => { + if (open && serverUp) fetchList() + }, + open ? 10000 : null, + ) + + // Periodic refresh when panel is closed (60 seconds) to keep badge accurate + useInterval( + () => { + if (!open && serverUp) fetchList() + }, + !open ? 60000 : null, + ) + + return ( + <div> + <NowPlayingButton count={count} onClick={handleMenuOpen} /> + <NowPlayingList + anchorEl={anchorEl} + open={open} + onClose={handleMenuClose} + entries={entries} + onLinkClick={handleLinkClick} + getArtistLink={getArtistLink} + /> + </div> + ) +} + +NowPlayingPanel.propTypes = {} + +export default NowPlayingPanel diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx new file mode 100644 index 000000000..4dd5dac8b --- /dev/null +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -0,0 +1,367 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { activityReducer } from '../reducers' +import NowPlayingPanel from './NowPlayingPanel' +import subsonic from '../subsonic' + +vi.mock('../subsonic', () => ({ + default: { + getNowPlaying: vi.fn(), + getAvatarUrl: vi.fn(() => '/avatar'), + getCoverArtUrl: vi.fn(() => '/cover'), + }, +})) + +// Create a mock for useMediaQuery +const mockUseMediaQuery = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + const redux = await import('react-redux') + return { + ...actual, + useTranslate: () => (x) => x, + useSelector: redux.useSelector, + useDispatch: redux.useDispatch, + Link: ({ to, children, onClick, ...props }) => ( + <a + href={to} + onClick={(e) => { + e.preventDefault() // Prevent navigation in tests + if (onClick) onClick(e) + }} + {...props} + > + {children} + </a> + ), + } +}) + +// Mock the specific Material-UI hooks we need +vi.mock('@material-ui/core/useMediaQuery', () => ({ + default: () => mockUseMediaQuery(), +})) + +vi.mock('@material-ui/core/styles/useTheme', () => ({ + default: () => ({ + breakpoints: { + down: () => '(max-width:959.95px)', // Mock breakpoint string + }, + }), +})) + +describe('<NowPlayingPanel />', () => { + const createMockStore = (overrides = {}) => { + const defaultState = { + activity: { + nowPlayingCount: 1, + serverStart: { startTime: Date.now() }, // Server is up by default + streamReconnected: 0, + ...overrides, + }, + } + return createStore( + combineReducers({ activity: activityReducer }), + defaultState, + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockUseMediaQuery.mockReturnValue(false) // Default to large screen + + subsonic.getNowPlaying.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + nowPlaying: { + entry: [ + { + playerId: 1, + username: 'u1', + playerName: 'Chrome Browser', + title: 'Song', + albumArtist: 'Artist', + albumId: 'album1', + albumArtistId: 'artist1', + minutesAgo: 2, + }, + ], + }, + }, + }, + }) + }) + + it('fetches and displays entries when opened', async () => { + const store = createMockStore() + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('Artist')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Artist' })).toHaveAttribute( + 'href', + '/artist/artist1/show', + ) + }) + }) + + it('displays player name after username', async () => { + const store = createMockStore() + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect( + screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), + ).toBeInTheDocument() + }) + }) + + it('handles entries without player name', async () => { + subsonic.getNowPlaying.mockResolvedValueOnce({ + json: { + 'subsonic-response': { + status: 'ok', + nowPlaying: { + entry: [ + { + playerId: 1, + username: 'u1', + title: 'Song', + albumArtist: 'Artist', + albumId: 'album1', + albumArtistId: 'artist1', + minutesAgo: 2, + }, + ], + }, + }, + }, + }) + + const store = createMockStore() + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + }) + }) + + it('shows empty message when no entries', async () => { + subsonic.getNowPlaying.mockResolvedValueOnce({ + json: { + 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, + }, + }) + const store = createMockStore({ nowPlayingCount: 0 }) + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Wait for initial fetch + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('nowPlaying.empty')).toBeInTheDocument() + }) + }) + + it('does not close panel when artist link is clicked on large screens', async () => { + mockUseMediaQuery.mockReturnValue(false) // Simulate large screen + + const store = createMockStore() + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + // Open the panel + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('Artist')).toBeInTheDocument() + }) + + // Check that the popover is open + expect(screen.getByRole('presentation')).toBeInTheDocument() + + // Click the artist link + fireEvent.click(screen.getByRole('link', { name: 'Artist' })) + + // Panel should remain open (popover should still be in document) + expect(screen.getByRole('presentation')).toBeInTheDocument() + expect(screen.getByText('Artist')).toBeInTheDocument() + }) + + it('does not fetch on mount when server is down', () => { + const store = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: null }, // Server is down + }) + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Should not have made initial fetch request due to server being down + expect(subsonic.getNowPlaying).not.toHaveBeenCalled() + }) + + it('does not fetch on stream reconnection when server is down', () => { + const store = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: null }, // Server is down + streamReconnected: Date.now(), // Stream reconnected + }) + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Should not have made fetch request due to server being down + expect(subsonic.getNowPlaying).not.toHaveBeenCalled() + }) + + it('does not double-fetch on server reconnection', () => { + const initialStore = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: null }, // Server initially down + streamReconnected: 0, + }) + const { rerender } = render( + <Provider store={initialStore}> + <NowPlayingPanel /> + </Provider>, + ) + + // Clear initial (empty) calls + vi.clearAllMocks() + + // Simulate server coming back up with stream reconnection (both state changes happen) + const reconnectedStore = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: Date.now() }, // Server back up + streamReconnected: Date.now(), // Stream reconnected + }) + rerender( + <Provider store={reconnectedStore}> + <NowPlayingPanel /> + </Provider>, + ) + + // Should only make one call despite both serverUp and streamReconnected changing + expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + }) + + it('skips polling when server is down', () => { + vi.useFakeTimers() + + const store = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: null }, // Server is down + }) + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Clear initial mount fetch + vi.clearAllMocks() + + // Advance time by 70 seconds to trigger polling interval + vi.advanceTimersByTime(70000) + + // Should not have made any additional requests due to server being down + expect(subsonic.getNowPlaying).not.toHaveBeenCalled() + + vi.useRealTimers() + }) + + it('resumes polling when server comes back up', () => { + vi.useFakeTimers() + + const store = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: null }, // Server is down + }) + const { rerender } = render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Clear initial mount fetch + vi.clearAllMocks() + + // Advance time - should not poll when server is down + vi.advanceTimersByTime(70000) + expect(subsonic.getNowPlaying).not.toHaveBeenCalled() + + // Update state to indicate server is back up + const updatedStore = createMockStore({ + nowPlayingCount: 1, + serverStart: { startTime: Date.now() }, // Server is back up + }) + rerender( + <Provider store={updatedStore}> + <NowPlayingPanel /> + </Provider>, + ) + + // Clear the fetch that happens due to initial mount of rerender + vi.clearAllMocks() + + // Advance time again - should now poll since server is up + vi.advanceTimersByTime(70000) + expect(subsonic.getNowPlaying).toHaveBeenCalled() + + vi.useRealTimers() + }) +}) diff --git a/ui/src/layout/useInitialScanStatus.jsx b/ui/src/layout/useInitialScanStatus.jsx new file mode 100644 index 000000000..913f68e47 --- /dev/null +++ b/ui/src/layout/useInitialScanStatus.jsx @@ -0,0 +1,18 @@ +import { useEffect } from 'react' +import { useDispatch } from 'react-redux' +import subsonic from '../subsonic' +import { scanStatusUpdate } from '../actions' + +export const useInitialScanStatus = () => { + const dispatch = useDispatch() + useEffect(() => { + subsonic + .getScanStatus() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + dispatch(scanStatusUpdate(data.scanStatus)) + } + }) + }, [dispatch]) +} diff --git a/ui/src/layout/useScanElapsedTime.jsx b/ui/src/layout/useScanElapsedTime.jsx new file mode 100644 index 000000000..af084a12d --- /dev/null +++ b/ui/src/layout/useScanElapsedTime.jsx @@ -0,0 +1,26 @@ +import { useEffect, useState, useRef } from 'react' +import { useInterval } from '../common' + +export const useScanElapsedTime = (scanning, elapsedTime) => { + const [elapsed, setElapsed] = useState(Number(elapsedTime) || 0) + const prevScanningRef = useRef(scanning) + + useEffect(() => { + const prevScanning = prevScanningRef.current + const serverElapsed = Number(elapsedTime) || 0 + + if (scanning !== prevScanning) { + // Scan has just started or stopped - sync with server value + setElapsed(serverElapsed) + } else if (!scanning) { + // Not scanning -> always reflect server value (initial load or after finish) + setElapsed(serverElapsed) + } + + prevScanningRef.current = scanning + }, [scanning, elapsedTime]) + + useInterval(() => setElapsed((prev) => prev + 1e9), scanning ? 1000 : null) + + return elapsed +} diff --git a/ui/src/layout/useScanElapsedTime.test.jsx b/ui/src/layout/useScanElapsedTime.test.jsx new file mode 100644 index 000000000..1e948504b --- /dev/null +++ b/ui/src/layout/useScanElapsedTime.test.jsx @@ -0,0 +1,135 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { vi } from 'vitest' +import { useScanElapsedTime } from './useScanElapsedTime' + +describe('useScanElapsedTime', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('increments elapsed time while scanning', () => { + const { result } = renderHook( + ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), + { + initialProps: { scanning: true, elapsed: 0 }, + }, + ) + + act(() => { + vi.advanceTimersByTime(3000) + }) + + expect(result.current).toBe(3e9) + }) + + it('stops incrementing when not scanning', () => { + const { result, rerender } = renderHook( + ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), + { + initialProps: { scanning: false, elapsed: 2e9 }, + }, + ) + + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(result.current).toBe(2e9) + + rerender({ scanning: true, elapsed: 2e9 }) + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(result.current).toBe(3e9) + }) + + it('initializes with server value when scan starts', () => { + const { result, rerender } = renderHook( + ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), + { + initialProps: { scanning: false, elapsed: 5e9 }, + }, + ) + + // Start scanning with a new elapsed time from server + rerender({ scanning: true, elapsed: 10e9 }) + + // Should use the server value when starting + expect(result.current).toBe(10e9) + + act(() => { + vi.advanceTimersByTime(2000) + }) + + // Should continue from server value + expect(result.current).toBe(12e9) + }) + + it('updates elapsed time when not scanning and server value changes', () => { + const { result, rerender } = renderHook( + ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), + { + initialProps: { scanning: false, elapsed: 0 }, + }, + ) + + // Server reports new elapsed time without changing scanning state + rerender({ scanning: false, elapsed: 8e9 }) + + expect(result.current).toBe(8e9) + }) + + it('ignores server updates during scanning', () => { + const { result, rerender } = renderHook( + ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), + { + initialProps: { scanning: true, elapsed: 0 }, + }, + ) + + act(() => { + vi.advanceTimersByTime(3000) + }) + + expect(result.current).toBe(3e9) + + // Server sends updated elapsed time during scan + rerender({ scanning: true, elapsed: 10e9 }) + + // Should ignore server update while scanning + expect(result.current).toBe(3e9) + + act(() => { + vi.advanceTimersByTime(1000) + }) + + // Should continue from local timer + expect(result.current).toBe(4e9) + }) + + it('uses final server value when scan ends', () => { + const { result, rerender } = renderHook( + ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), + { + initialProps: { scanning: true, elapsed: 0 }, + }, + ) + + act(() => { + vi.advanceTimersByTime(3000) + }) + + expect(result.current).toBe(3e9) + + // Scan ends with final server value + rerender({ scanning: false, elapsed: 5e9 }) + + // Should use the final server value + expect(result.current).toBe(5e9) + }) +}) diff --git a/ui/src/missing/DeleteMissingFilesButton.jsx b/ui/src/missing/DeleteMissingFilesButton.jsx index 7b4aae875..f02bb81ae 100644 --- a/ui/src/missing/DeleteMissingFilesButton.jsx +++ b/ui/src/missing/DeleteMissingFilesButton.jsx @@ -1,7 +1,6 @@ import React, { useState } from 'react' import DeleteIcon from '@material-ui/icons/Delete' -import { makeStyles } from '@material-ui/core/styles' -import { fade } from '@material-ui/core/styles/colorManipulator' +import { makeStyles, alpha } from '@material-ui/core/styles' import clsx from 'clsx' import { Button, @@ -17,7 +16,7 @@ const useStyles = makeStyles( deleteButton: { color: theme.palette.error.main, '&:hover': { - backgroundColor: fade(theme.palette.error.main, 0.12), + backgroundColor: alpha(theme.palette.error.main, 0.12), // Reset on mouse devices '@media (hover: none)': { backgroundColor: 'transparent', @@ -29,13 +28,14 @@ const useStyles = makeStyles( ) const DeleteMissingFilesButton = (props) => { - const { selectedIds, className } = props + const { selectedIds, className, deleteAll = false } = props const [open, setOpen] = useState(false) const unselectAll = useUnselectAll() const refresh = useRefresh() const notify = useNotify() - const [deleteMany, { loading }] = useDeleteMany('missing', selectedIds, { + const ids = deleteAll ? [] : selectedIds + const [deleteMany, { loading }] = useDeleteMany('missing', ids, { onSuccess: () => { notify('resources.missing.notifications.removed') refresh() @@ -57,7 +57,11 @@ const DeleteMissingFilesButton = (props) => { <> <Button onClick={handleClick} - label="ra.action.remove" + label={ + deleteAll + ? 'resources.missing.actions.remove_all' + : 'ra.action.remove' + } key="button" className={clsx('ra-delete-button', classes.deleteButton, className)} > @@ -66,8 +70,16 @@ const DeleteMissingFilesButton = (props) => { <Confirm isOpen={open} loading={loading} - title="message.remove_missing_title" - content="message.remove_missing_content" + title={ + deleteAll + ? 'message.remove_all_missing_title' + : 'message.remove_missing_title' + } + content={ + deleteAll + ? 'message.remove_all_missing_content' + : 'message.remove_missing_content' + } onConfirm={handleConfirm} onClose={handleDialogClose} /> diff --git a/ui/src/missing/DeleteMissingFilesButton.test.jsx b/ui/src/missing/DeleteMissingFilesButton.test.jsx new file mode 100644 index 000000000..cafae9afe --- /dev/null +++ b/ui/src/missing/DeleteMissingFilesButton.test.jsx @@ -0,0 +1,42 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' +import * as RA from 'react-admin' + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + Button: ({ children, onClick, label }) => ( + <button onClick={onClick}>{label || children}</button> + ), + Confirm: ({ isOpen }) => (isOpen ? <div data-testid="confirm" /> : null), + useNotify: vi.fn(), + useDeleteMany: vi.fn(() => [vi.fn(), { loading: false }]), + useRefresh: vi.fn(), + useUnselectAll: vi.fn(), + } +}) + +describe('DeleteMissingFilesButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses remove_all label when deleteAll is true', () => { + const { getByRole } = render(<DeleteMissingFilesButton deleteAll />) + expect(getByRole('button').textContent).toBe( + 'resources.missing.actions.remove_all', + ) + }) + + it('calls useDeleteMany with empty ids when deleteAll is true', () => { + render(<DeleteMissingFilesButton deleteAll />) + expect(RA.useDeleteMany).toHaveBeenCalledWith( + 'missing', + [], + expect.any(Object), + ) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 8f73023fa..74711eed0 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,4 +1,4 @@ -import { List, SizeField } from '../common/index' +import { List, SizeField, useResourceRefresh } from '../common/index' import { Datagrid, DateField, @@ -8,6 +8,7 @@ import { } from 'react-admin' import jsonExport from 'jsonexport/dist' import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' +import MissingListActions from './MissingListActions.jsx' const exporter = (files) => { const filesToExport = files.map((file) => { @@ -30,11 +31,13 @@ const MissingPagination = (props) => ( ) const MissingFilesList = (props) => { + useResourceRefresh('song') return ( <List {...props} sort={{ field: 'updated_at', order: 'DESC' }} exporter={exporter} + actions={<MissingListActions />} bulkActionButtons={<BulkActionButtons />} perPage={50} pagination={<MissingPagination />} diff --git a/ui/src/missing/MissingListActions.jsx b/ui/src/missing/MissingListActions.jsx new file mode 100644 index 000000000..4bbf77115 --- /dev/null +++ b/ui/src/missing/MissingListActions.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import { TopToolbar, ExportButton } from 'react-admin' +import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' + +const MissingListActions = (props) => ( + <TopToolbar {...props}> + <ExportButton /> + <DeleteMissingFilesButton deleteAll /> + </TopToolbar> +) + +export default MissingListActions diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index f8c06bba7..acccb15f7 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -1,37 +1,81 @@ -import { Card, CardContent, Typography } from '@material-ui/core' +import { + Card, + CardContent, + CardMedia, + Typography, + useMediaQuery, +} from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useTranslate } from 'react-admin' +import { useCallback, useState, useEffect } from 'react' +import Lightbox from 'react-image-lightbox' +import 'react-image-lightbox/style.css' import { CollapsibleComment, DurationField, SizeField } from '../common' +import subsonic from '../subsonic' const useStyles = makeStyles( (theme) => ({ - container: { + root: { [theme.breakpoints.down('xs')]: { padding: '0.7em', - minWidth: '24em', + minWidth: '20em', }, [theme.breakpoints.up('sm')]: { padding: '1em', minWidth: '32em', }, }, + cardContents: { + display: 'flex', + }, details: { - display: 'inline-block', - verticalAlign: 'top', + display: 'flex', + flexDirection: 'column', + }, + content: { + flex: '2 0 auto', + }, + coverParent: { [theme.breakpoints.down('xs')]: { - width: '14em', + height: '8em', + width: '8em', + minWidth: '8em', }, [theme.breakpoints.up('sm')]: { - width: '26em', + height: '10em', + width: '10em', + minWidth: '10em', }, [theme.breakpoints.up('lg')]: { - width: '38em', + height: '15em', + width: '15em', + minWidth: '15em', }, + backgroundColor: 'transparent', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + cover: { + objectFit: 'contain', + cursor: 'pointer', + display: 'block', + width: '100%', + height: '100%', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, }, title: { - whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + wordBreak: 'break-word', + }, + stats: { + marginTop: '1em', + marginBottom: '0.5em', }, }), { @@ -43,31 +87,95 @@ const PlaylistDetails = (props) => { const { record = {} } = props const translate = useTranslate() const classes = useStyles() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) + const [isLightboxOpen, setLightboxOpen] = useState(false) + const [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) + + const imageUrl = subsonic.getCoverArtUrl(record, 300, true) + const fullImageUrl = subsonic.getCoverArtUrl(record) + + // Reset image state when playlist changes + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + + const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) return ( - <Card className={classes.container}> - <CardContent className={classes.details}> - <Typography variant="h5" className={classes.title}> - {record.name || translate('ra.page.loading')} - </Typography> - <Typography component="p"> - {record.songCount ? ( - <span> - {record.songCount}{' '} - {translate('resources.song.name', { - smart_count: record.songCount, - })} - {' · '} - <DurationField record={record} source={'duration'} /> - {' · '} - <SizeField record={record} source={'size'} /> - </span> - ) : ( - <span> </span> - )} - </Typography> - <CollapsibleComment record={record} /> - </CardContent> + <Card className={classes.root}> + <div className={classes.cardContents}> + <div className={classes.coverParent}> + <CardMedia + key={record.id} // Force re-render when playlist changes + component={'img'} + src={imageUrl} + width="400" + height="400" + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} + title={record.name} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} + /> + </div> + <div className={classes.details}> + <CardContent className={classes.content}> + <Typography + variant={isDesktop ? 'h5' : 'h6'} + className={classes.title} + > + {record.name || translate('ra.page.loading')} + </Typography> + <Typography component="p" className={classes.stats}> + {record.songCount ? ( + <span> + {record.songCount}{' '} + {translate('resources.song.name', { + smart_count: record.songCount, + })} + {' · '} + <DurationField record={record} source={'duration'} /> + {' · '} + <SizeField record={record} source={'size'} /> + </span> + ) : ( + <span> </span> + )} + </Typography> + <CollapsibleComment record={record} /> + </CardContent> + </div> + </div> + {isLightboxOpen && !imageError && ( + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.name} + mainSrc={fullImageUrl} + onCloseRequest={handleCloseLightbox} + /> + )} </Card> ) } diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index ca300bbd0..f0cb472b1 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -5,12 +5,13 @@ import { useShowContext, useShowController, Pagination, + Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { Title, canChangeTracks } from '../common' +import { Title, canChangeTracks, useResourceRefresh } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -27,9 +28,11 @@ const PlaylistShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() + useResourceRefresh('song') return ( <> + {record && <RaTitle title={<Title subTitle={record.name} />} />} {record && <PlaylistDetails {...context} />} {record && ( <ReferenceManyField diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index f249c9793..4292562ab 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useEffect, useMemo } from 'react' import { BulkActionsToolbar, ListToolbar, @@ -26,11 +26,13 @@ import { useResourceRefresh, DateField, ArtistLinkField, + RatingField, } from '../common' import { AlbumLinkField } from '../song/AlbumLinkField' import { playTracks } from '../actions' import PlaylistSongBulkActions from './PlaylistSongBulkActions' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' +import config from '../config' const useStyles = makeStyles( (theme) => ({ @@ -66,11 +68,17 @@ const useStyles = makeStyles( '& $contextMenu': { visibility: 'visible', }, + '& $ratingField': { + visibility: 'visible', + }, }, }, contextMenu: { visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), }, + ratingField: { + visibility: 'hidden', + }, }), { name: 'RaList' }, ) @@ -84,7 +92,8 @@ const ReorderableList = ({ readOnly, children, ...rest }) => { const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const listContext = useListContext() - const { data, ids, selectedIds, onUnselectItems, refetch } = listContext + const { data, ids, selectedIds, onUnselectItems, refetch, setPage } = + listContext const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) const dispatch = useDispatch() @@ -93,6 +102,11 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const version = useVersion() useResourceRefresh('song', 'playlist') + useEffect(() => { + setPage(1) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, [playlistId, setPage]) + const onAddToPlaylist = useCallback( (pls) => { if (pls.id === playlistId) { @@ -149,12 +163,22 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { playCount: isDesktop && ( <NumberField source="playCount" sortByOrder={'DESC'} /> ), - playDate: <DateField source="playDate" sortByOrder={'DESC'} showTime />, + playDate: isDesktop && ( + <DateField source="playDate" sortByOrder={'DESC'} showTime /> + ), quality: isDesktop && <QualityInfo source="quality" sortable={false} />, channels: isDesktop && <NumberField source="channels" />, bpm: isDesktop && <NumberField source="bpm" />, + rating: config.enableStarRating && ( + <RatingField + source="rating" + sortByOrder={'DESC'} + resource={'song'} + className={classes.ratingField} + /> + ), } - }, [isDesktop, classes.draggable]) + }, [isDesktop, classes.draggable, classes.ratingField]) const columns = useSelectedFields({ resource: 'playlistTrack', @@ -166,6 +190,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { 'playCount', 'playDate', 'albumArtist', + 'rating', ], }) @@ -205,7 +230,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { {columns} <SongContextMenu onAddToPlaylist={onAddToPlaylist} - showLove={false} + showLove={true} className={classes.contextMenu} /> </SongDatagrid> diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 551c4c691..3d1adacc9 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -6,7 +6,6 @@ import { DateField, EditButton, Filter, - List, sanitizeListRestProps, SearchInput, SimpleList, @@ -15,6 +14,7 @@ import { UrlField, useTranslate, } from 'react-admin' +import { List } from '../common' import { ToggleFieldsMenu, useSelectedFields } from '../common' import { StreamField } from './StreamField' import { setTrack } from '../actions' diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 2b6d2741c..8238e395a 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -2,6 +2,8 @@ import { EVENT_REFRESH_RESOURCE, EVENT_SCAN_STATUS, EVENT_SERVER_START, + EVENT_NOW_PLAYING_COUNT, + EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -14,6 +16,8 @@ const initialState = { elapsedTime: 0, }, serverStart: { version: config.version }, + nowPlayingCount: 0, + streamReconnected: 0, // Timestamp of last reconnection } export const activityReducer = (previousState = initialState, payload) => { @@ -40,6 +44,10 @@ export const activityReducer = (previousState = initialState, payload) => { resources: data, }, } + case EVENT_NOW_PLAYING_COUNT: + return { ...previousState, nowPlayingCount: data.count } + case EVENT_STREAM_RECONNECTED: + return { ...previousState, streamReconnected: Date.now() } default: return previousState } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index a1389e3d2..c9db38dbb 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -1,5 +1,10 @@ import { activityReducer } from './activityReducer' -import { EVENT_SCAN_STATUS, EVENT_SERVER_START } from '../actions' +import { + EVENT_SCAN_STATUS, + EVENT_SERVER_START, + EVENT_NOW_PLAYING_COUNT, + EVENT_STREAM_RECONNECTED, +} from '../actions' import config from '../config' describe('activityReducer', () => { @@ -12,6 +17,8 @@ describe('activityReducer', () => { elapsedTime: 0, }, serverStart: { version: config.version }, + nowPlayingCount: 0, + streamReconnected: 0, } it('returns the initial state when no action is specified', () => { @@ -116,4 +123,26 @@ describe('activityReducer', () => { startTime: Date.parse('2023-01-01T00:00:00Z'), }) }) + + it('handles EVENT_NOW_PLAYING_COUNT', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 5 }, + } + const newState = activityReducer(initialState, action) + expect(newState.nowPlayingCount).toEqual(5) + }) + + it('handles EVENT_STREAM_RECONNECTED', () => { + const action = { + type: EVENT_STREAM_RECONNECTED, + data: {}, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.streamReconnected).toBeGreaterThanOrEqual(beforeTimestamp) + expect(newState.streamReconnected).toBeLessThanOrEqual(afterTimestamp) + }) }) diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js index 04f235c5f..e1a77f100 100644 --- a/ui/src/reducers/dialogReducer.js +++ b/ui/src/reducers/dialogReducer.js @@ -13,6 +13,8 @@ import { EXTENDED_INFO_CLOSE, LISTENBRAINZ_TOKEN_OPEN, LISTENBRAINZ_TOKEN_CLOSE, + SAVE_QUEUE_OPEN, + SAVE_QUEUE_CLOSE, SHARE_MENU_OPEN, SHARE_MENU_CLOSE, } from '../actions' @@ -169,3 +171,18 @@ export const listenBrainzTokenDialogReducer = ( return previousState } } + +export const saveQueueDialogReducer = ( + previousState = { open: false }, + payload, +) => { + const { type } = payload + switch (type) { + case SAVE_QUEUE_OPEN: + return { ...previousState, open: true } + case SAVE_QUEUE_CLOSE: + return { ...previousState, open: false } + default: + return previousState + } +} diff --git a/ui/src/share/ShareList.jsx b/ui/src/share/ShareList.jsx index 183d6af6b..48ab44e18 100644 --- a/ui/src/share/ShareList.jsx +++ b/ui/src/share/ShareList.jsx @@ -2,13 +2,13 @@ import { Datagrid, FunctionField, BooleanField, - List, NumberField, SimpleList, TextField, useNotify, useTranslate, } from 'react-admin' +import { List } from '../common' import React from 'react' import { IconButton, Link, useMediaQuery } from '@material-ui/core' import ShareIcon from '@material-ui/icons/Share' diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx index 38493d3ce..2c50275ed 100644 --- a/ui/src/share/SharePlayer.jsx +++ b/ui/src/share/SharePlayer.jsx @@ -29,7 +29,7 @@ const SharePlayer = () => { return { name: s.title, musicSrc: shareStreamUrl(s.id), - cover: shareCoverUrl(s.id), + cover: shareCoverUrl(s.id, true), singer: s.artist, duration: s.duration, } diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 02d28d44f..f067e11d2 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -8,6 +8,8 @@ import { SearchInput, TextField, useTranslate, + NullableBooleanInput, + usePermissions, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import FavoriteIcon from '@material-ui/icons/Favorite' @@ -67,6 +69,8 @@ const useStyles = makeStyles({ const SongFilter = (props) => { const classes = useStyles() const translate = useTranslate() + const { permissions } = usePermissions() + const isAdmin = permissions === 'admin' return ( <Filter {...props} variant={'outlined'}> <SearchInput source="title" alwaysOn /> @@ -121,6 +125,7 @@ const SongFilter = (props) => { defaultValue={true} /> )} + {isAdmin && <NullableBooleanInput source="missing" />} </Filter> ) } @@ -177,7 +182,9 @@ const SongList = (props) => { ), comment: <TextField source="comment" />, path: <PathField source="path" />, - createdAt: <DateField source="createdAt" showTime />, + createdAt: ( + <DateField source="createdAt" sortBy="recently_added" showTime /> + ), } }, [isDesktop, classes.ratingField]) diff --git a/ui/src/song/SongListActions.jsx b/ui/src/song/SongListActions.jsx index 82dbe789e..44fe9b5f5 100644 --- a/ui/src/song/SongListActions.jsx +++ b/ui/src/song/SongListActions.jsx @@ -24,6 +24,7 @@ export const SongListActions = ({ const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) return ( <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + <ShuffleAllButton filters={filterValues} /> {filters && cloneElement(filters, { resource, @@ -32,7 +33,6 @@ export const SongListActions = ({ filterValues, context: 'button', })} - <ShuffleAllButton filters={filterValues} /> {isNotSmall && <ToggleFieldsMenu resource="song" />} </TopToolbar> ) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index ce5116bcb..ad7a391e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -31,15 +31,16 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true) => +const scrobble = (id, time, submission = true, position = null) => httpClient( url('scrobble', id, { ...(submission && time && { time }), submission, + ...(!submission && position !== null && { position }), }), ) -const nowPlaying = (id) => scrobble(id, null, false) +const nowPlaying = (id, position = null) => scrobble(id, null, false, position) const star = (id) => httpClient(url('star', id)) @@ -54,6 +55,16 @@ const startScan = (options) => httpClient(url('startScan', null, options)) const getScanStatus = () => httpClient(url('getScanStatus')) +const getNowPlaying = () => httpClient(url('getNowPlaying')) + +const getAvatarUrl = (username, size) => + baseUrl( + url('getAvatar', null, { + username, + ...(size && { size }), + }), + ) + const getCoverArtUrl = (record, size, square) => { const options = { ...(record.updatedAt && { _: record.updatedAt }), @@ -61,11 +72,14 @@ const getCoverArtUrl = (record, size, square) => { ...(square && { square }), } - // TODO Move this logic to server. `song` and `album` should have a CoverArtID + // TODO Move this logic to server if (record.album) { return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) } else if (record.albumArtist) { return baseUrl(url('getCoverArt', 'al-' + record.id, options)) + } else if (record.sync !== undefined) { + // This is a playlist + return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) } else { return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) } @@ -79,6 +93,14 @@ const getAlbumInfo = (id) => { return httpClient(url('getAlbumInfo', id)) } +const getSimilarSongs2 = (id, count = 100) => { + return httpClient(url('getSimilarSongs2', id, { count })) +} + +const getTopSongs = (artist, count = 50) => { + return httpClient(url('getTopSongs', null, { artist, count })) +} + const streamUrl = (id, options) => { return baseUrl( url('stream', id, { @@ -99,8 +121,12 @@ export default { setRating, startScan, getScanStatus, + getNowPlaying, getCoverArtUrl, + getAvatarUrl, streamUrl, getAlbumInfo, getArtistInfo, + getTopSongs, + getSimilarSongs2, } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js new file mode 100644 index 000000000..1e0fbeaa6 --- /dev/null +++ b/ui/src/subsonic/index.test.js @@ -0,0 +1,129 @@ +import { vi } from 'vitest' +import subsonic from './index' + +describe('getCoverArtUrl', () => { + beforeEach(() => { + // Mock window.location + delete window.location + window.location = { href: 'http://localhost:3000/app' } + + // Mock localStorage values required by subsonic + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + setItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should return playlist cover art URL for records with sync property', () => { + const playlistRecord = { + id: 'playlist-123', + sync: true, + updatedAt: '2023-01-01T00:00:00Z', + } + + const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) + + expect(url).toContain('pl-playlist-123') + expect(url).toContain('size=300') + expect(url).toContain('square=true') + expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') + }) + + it('should add timestamp for playlists without updatedAt', () => { + const playlistRecord = { + id: 'playlist-123', + sync: true, + } + + const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) + + expect(url).toContain('pl-playlist-123') + expect(url).toContain('size=300') + expect(url).toContain('square=true') + expect(url).not.toContain('_=') + }) + + it('should return album cover art URL for records with albumArtist', () => { + const albumRecord = { + id: 'album-123', + albumArtist: 'Test Artist', + updatedAt: '2023-01-01T00:00:00Z', + } + + const url = subsonic.getCoverArtUrl(albumRecord, 300, true) + + expect(url).toContain('al-album-123') + expect(url).toContain('size=300') + expect(url).toContain('square=true') + }) + + it('should return media file cover art URL for records with album', () => { + const songRecord = { + id: 'song-123', + album: 'Test Album', + updatedAt: '2023-01-01T00:00:00Z', + } + + const url = subsonic.getCoverArtUrl(songRecord, 300, true) + + expect(url).toContain('mf-song-123') + expect(url).toContain('size=300') + expect(url).toContain('square=true') + }) + + it('should return artist cover art URL for other records', () => { + const artistRecord = { + id: 'artist-123', + updatedAt: '2023-01-01T00:00:00Z', + } + + const url = subsonic.getCoverArtUrl(artistRecord, 300, true) + + expect(url).toContain('ar-artist-123') + expect(url).toContain('size=300') + expect(url).toContain('square=true') + }) + + it('should handle records without updatedAt', () => { + const record = { + id: 'test-123', + } + + const url = subsonic.getCoverArtUrl(record) + + expect(url).toContain('ar-test-123') + expect(url).not.toContain('_=') + }) +}) + +describe('getAvatarUrl', () => { + beforeEach(() => { + // Mock localStorage values required by subsonic + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should include username parameter', () => { + const url = subsonic.getAvatarUrl('john') + expect(url).toContain('getAvatar') + expect(url).toContain('username=john') + }) +}) diff --git a/ui/src/themes/nord.js b/ui/src/themes/nord.js index 8c346eefe..5420bbc60 100644 --- a/ui/src/themes/nord.js +++ b/ui/src/themes/nord.js @@ -259,7 +259,6 @@ export default { }, details: { fontSize: '.875rem', - minWidth: '75vw', color: 'rgba(255,255,255, 0.8)', }, }, diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js index 703d8159e..c40ed20aa 100644 --- a/ui/src/themes/spotify.js +++ b/ui/src/themes/spotify.js @@ -204,7 +204,6 @@ export default { }, details: { fontSize: '.875rem', - minWidth: '75vw', color: 'rgba(255,255,255, 0.8)', }, }, @@ -243,6 +242,64 @@ export default { NDPlaylistShow: { playlistActions: musicListActions, }, + NDArtistShow: { + actions: { + padding: '2rem 0', + alignItems: 'center', + overflow: 'visible', + minHeight: '120px', + '@global': { + button: { + border: '1px solid transparent', + backgroundColor: 'inherit', + color: '#b3b3b3', + margin: '0 0.5rem', + '&:hover': { + border: '1px solid #b3b3b3', + backgroundColor: 'inherit !important', + }, + }, + // Hide shuffle button label (first button) + 'button:first-child>span:first-child>span': { + display: 'none', + }, + // Style shuffle button (first button) + 'button:first-child': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + background: spotifyGreen['500'], + color: '#fff', + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${spotifyGreen['500']} !important`, + border: 0, + }, + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: '#b3b3b3', + }, + }, + }, + actionsContainer: { + overflow: 'visible', + }, + }, NDAudioPlayer: { audioTitle: { color: '#fff', diff --git a/ui/src/themes/theme.test.js b/ui/src/themes/theme.test.js new file mode 100644 index 000000000..b65c3a5fe --- /dev/null +++ b/ui/src/themes/theme.test.js @@ -0,0 +1,14 @@ +import themes from './index' +import { describe, it, expect } from 'vitest' + +describe('NDPlaylistDetails styles', () => { + const themeEntries = Object.entries(themes) + + it.each(themeEntries)( + '%s should not set minWidth on details', + (themeName, theme) => { + const details = theme.overrides?.NDPlaylistDetails?.details + expect(details?.minWidth).toBeUndefined() + }, + ) +}) diff --git a/ui/src/user/DeleteUserButton.jsx b/ui/src/user/DeleteUserButton.jsx index 9bf133fb4..8d78e6bcd 100644 --- a/ui/src/user/DeleteUserButton.jsx +++ b/ui/src/user/DeleteUserButton.jsx @@ -1,7 +1,6 @@ import React from 'react' import DeleteIcon from '@material-ui/icons/Delete' -import { makeStyles } from '@material-ui/core/styles' -import { fade } from '@material-ui/core/styles/colorManipulator' +import { makeStyles, alpha } from '@material-ui/core/styles' import clsx from 'clsx' import { useDeleteWithConfirmController, @@ -16,7 +15,7 @@ const useStyles = makeStyles( deleteButton: { color: theme.palette.error.main, '&:hover': { - backgroundColor: fade(theme.palette.error.main, 0.12), + backgroundColor: alpha(theme.palette.error.main, 0.12), // Reset on mouse devices '@media (hover: none)': { backgroundColor: 'transparent', diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index fdae496e0..5788096df 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -33,8 +33,14 @@ export const shareDownloadUrl = (id) => { return shareUrl(config.publicBaseUrl + '/d/' + id) } -export const shareCoverUrl = (id) => { - return shareUrl(config.publicBaseUrl + '/img/' + id + '?size=300') +export const shareCoverUrl = (id, square) => { + return shareUrl( + config.publicBaseUrl + + '/img/' + + id + + '?size=300' + + (square ? '&square=true' : ''), + ) } export const docsUrl = (path) => `https://www.navidrome.org${path}` diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index 182d1d12a..cac41be7b 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -1,8 +1,10 @@ package cache import ( + "context" "errors" "fmt" + "runtime" "sync/atomic" "time" @@ -17,6 +19,8 @@ type SimpleCache[K comparable, V any] interface { GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) Keys() []K Values() []V + Len() int + OnExpiration(fn func(K, V)) func() } type Options struct { @@ -39,9 +43,17 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { } c := ttlcache.New[K, V](opts...) - return &simpleCache[K, V]{ + cache := &simpleCache[K, V]{ data: c, } + go cache.data.Start() + + // Automatic cleanup to prevent goroutine leak when cache is garbage collected + runtime.AddCleanup(cache, func(ttlCache *ttlcache.Cache[K, V]) { + ttlCache.Stop() + }, cache.data) + + return cache } const evictionTimeout = 1 * time.Hour @@ -127,3 +139,15 @@ func (c *simpleCache[K, V]) Values() []V { }) return res } + +func (c *simpleCache[K, V]) Len() int { + return c.data.Len() +} + +func (c *simpleCache[K, V]) OnExpiration(fn func(K, V)) func() { + return c.data.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[K, V]) { + if reason == ttlcache.EvictionReasonExpired { + fn(item.Key(), item.Value()) + } + }) +} diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go index 88dab5e07..45ba2c966 100644 --- a/utils/cache/simple_cache_test.go +++ b/utils/cache/simple_cache_test.go @@ -143,5 +143,19 @@ var _ = Describe("SimpleCache", func() { Expect(cache.Get("key0")).To(Equal("value0")) }) }) + + Describe("OnExpiration", func() { + It("should call callback when item expires", func() { + cache = NewSimpleCache[string, string]() + expired := make(chan struct{}) + cache.OnExpiration(func(k, v string) { close(expired) }) + Expect(cache.AddWithTTL("key", "value", 10*time.Millisecond)).To(Succeed()) + select { + case <-expired: + case <-time.After(100 * time.Millisecond): + Fail("expiration callback not called") + } + }) + }) }) }) diff --git a/utils/chain/chain_test.go b/utils/chain/chain_test.go deleted file mode 100644 index 1c6010fb3..000000000 --- a/utils/chain/chain_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package chain_test - -import ( - "errors" - "testing" - - "github.com/navidrome/navidrome/utils/chain" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestChain(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "chain Suite") -} - -var _ = Describe("RunSequentially", func() { - It("should return nil if no functions are provided", func() { - err := chain.RunSequentially() - Expect(err).To(BeNil()) - }) - - It("should return nil if all functions succeed", func() { - err := chain.RunSequentially( - func() error { return nil }, - func() error { return nil }, - ) - Expect(err).To(BeNil()) - }) - - It("should return the error from the first failing function", func() { - expectedErr := errors.New("error in function 2") - err := chain.RunSequentially( - func() error { return nil }, - func() error { return expectedErr }, - func() error { return errors.New("error in function 3") }, - ) - Expect(err).To(Equal(expectedErr)) - }) - - It("should not run functions after the first failing function", func() { - expectedErr := errors.New("error in function 1") - var runCount int - err := chain.RunSequentially( - func() error { runCount++; return expectedErr }, - func() error { runCount++; return nil }, - ) - Expect(err).To(Equal(expectedErr)) - Expect(runCount).To(Equal(1)) - }) -}) diff --git a/utils/chrono/meter_test.go b/utils/chrono/meter_test.go index 1e223ea04..8e18d37ad 100644 --- a/utils/chrono/meter_test.go +++ b/utils/chrono/meter_test.go @@ -16,7 +16,10 @@ func TestChrono(t *testing.T) { RunSpecs(t, "Chrono Suite") } -// Note: These tests may be flaky due to the use of time.Sleep. +// Note: These tests use longer sleep durations and generous tolerances to avoid flakiness +// due to system scheduling delays. For a more elegant approach in the future, consider +// using Go 1.24's experimental testing/synctest package with GOEXPERIMENT=synctest. + var _ = Describe("Meter", func() { var meter *Meter @@ -27,44 +30,62 @@ var _ = Describe("Meter", func() { Describe("Stop", func() { It("should return the elapsed time", func() { meter.Start() - time.Sleep(20 * time.Millisecond) + time.Sleep(50 * time.Millisecond) elapsed := meter.Stop() - Expect(elapsed).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond)) + // Use generous tolerance to account for system scheduling delays + Expect(elapsed).To(BeNumerically(">=", 30*time.Millisecond)) + Expect(elapsed).To(BeNumerically("<=", 200*time.Millisecond)) }) It("should accumulate elapsed time on multiple starts and stops", func() { + // First cycle meter.Start() - time.Sleep(20 * time.Millisecond) - meter.Stop() + time.Sleep(50 * time.Millisecond) + firstElapsed := meter.Stop() + // Second cycle meter.Start() - time.Sleep(20 * time.Millisecond) - elapsed := meter.Stop() + time.Sleep(50 * time.Millisecond) + totalElapsed := meter.Stop() - Expect(elapsed).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond)) + // Test that time accumulates (second measurement should be greater than first) + Expect(totalElapsed).To(BeNumerically(">", firstElapsed)) + + // Test that accumulated time is reasonable (should be roughly double the first) + Expect(totalElapsed).To(BeNumerically(">=", time.Duration(float64(firstElapsed)*1.5))) + Expect(totalElapsed).To(BeNumerically("<=", firstElapsed*3)) + + // Sanity check: total should be at least 60ms (allowing for some timing variance) + Expect(totalElapsed).To(BeNumerically(">=", 60*time.Millisecond)) }) }) Describe("Elapsed", func() { It("should return the total elapsed time", func() { meter.Start() - time.Sleep(20 * time.Millisecond) + time.Sleep(50 * time.Millisecond) meter.Stop() // Should not count the time the meter was stopped - time.Sleep(20 * time.Millisecond) + time.Sleep(50 * time.Millisecond) meter.Start() - time.Sleep(20 * time.Millisecond) + time.Sleep(50 * time.Millisecond) meter.Stop() - Expect(meter.Elapsed()).To(BeNumerically("~", 40*time.Millisecond, 20*time.Millisecond)) + elapsed := meter.Elapsed() + // Should be roughly 100ms (2 x 50ms), but allow for significant variance + Expect(elapsed).To(BeNumerically(">=", 60*time.Millisecond)) + Expect(elapsed).To(BeNumerically("<=", 300*time.Millisecond)) }) It("should include the current running time if started", func() { meter.Start() - time.Sleep(20 * time.Millisecond) - Expect(meter.Elapsed()).To(BeNumerically("~", 20*time.Millisecond, 10*time.Millisecond)) + time.Sleep(50 * time.Millisecond) + elapsed := meter.Elapsed() + // Use generous tolerance to account for system scheduling delays + Expect(elapsed).To(BeNumerically(">=", 30*time.Millisecond)) + Expect(elapsed).To(BeNumerically("<=", 200*time.Millisecond)) }) }) }) diff --git a/utils/files.go b/utils/files.go index 59988340c..9bdc262c5 100644 --- a/utils/files.go +++ b/utils/files.go @@ -17,3 +17,9 @@ func BaseName(filePath string) string { p := path.Base(filePath) return strings.TrimSuffix(p, path.Ext(p)) } + +// FileExists checks if a file or directory exists +func FileExists(path string) bool { + _, err := os.Stat(path) + return err == nil || !os.IsNotExist(err) +} diff --git a/utils/req/req.go b/utils/req/req.go index cf498f322..f9fa5724b 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -35,6 +35,25 @@ func (r *Values) String(param string) (string, error) { return v, nil } +func (r *Values) StringPtr(param string) *string { + var v *string + if _, exists := r.URL.Query()[param]; exists { + s := r.URL.Query().Get(param) + v = &s + } + return v +} + +func (r *Values) BoolPtr(param string) *bool { + var v *bool + if _, exists := r.URL.Query()[param]; exists { + s := r.URL.Query().Get(param) + b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") + v = &b + } + return v +} + func (r *Values) StringOr(param, def string) string { v, _ := r.String(param) if v == "" { diff --git a/utils/req/req_test.go b/utils/req/req_test.go index 041aca220..e710365bd 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -219,4 +219,59 @@ var _ = Describe("Request Helpers", func() { }) }) }) + + Describe("ParamStringPtr", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?a=123", nil)) + }) + + It("returns pointer to string if param exists", func() { + ptr := r.StringPtr("a") + Expect(ptr).ToNot(BeNil()) + Expect(*ptr).To(Equal("123")) + }) + + It("returns nil if param does not exist", func() { + ptr := r.StringPtr("xx") + Expect(ptr).To(BeNil()) + }) + + It("returns pointer to empty string if param exists but is empty", func() { + r = req.Params(httptest.NewRequest("GET", "/ping?a=", nil)) + ptr := r.StringPtr("a") + Expect(ptr).ToNot(BeNil()) + Expect(*ptr).To(Equal("")) + }) + }) + + Describe("ParamBoolPtr", func() { + Context("value is true", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?b=true", nil)) + }) + + It("returns pointer to true if param is 'true'", func() { + ptr := r.BoolPtr("b") + Expect(ptr).ToNot(BeNil()) + Expect(*ptr).To(BeTrue()) + }) + }) + + Context("value is false", func() { + BeforeEach(func() { + r = req.Params(httptest.NewRequest("GET", "/ping?b=false", nil)) + }) + + It("returns pointer to false if param is 'false'", func() { + ptr := r.BoolPtr("b") + Expect(ptr).ToNot(BeNil()) + Expect(*ptr).To(BeFalse()) + }) + }) + + It("returns nil if param does not exist", func() { + ptr := r.BoolPtr("xx") + Expect(ptr).To(BeNil()) + }) + }) }) diff --git a/utils/chain/chain.go b/utils/run/run.go similarity index 68% rename from utils/chain/chain.go rename to utils/run/run.go index b93dbd93d..182eec42c 100644 --- a/utils/chain/chain.go +++ b/utils/run/run.go @@ -1,11 +1,11 @@ -package chain +package run import "golang.org/x/sync/errgroup" -// RunSequentially runs the given functions sequentially, +// Sequentially runs the given functions sequentially, // If any function returns an error, it stops the execution and returns that error. // If all functions return nil, it returns nil. -func RunSequentially(fs ...func() error) error { +func Sequentially(fs ...func() error) error { for _, f := range fs { if err := f(); err != nil { return err @@ -14,9 +14,9 @@ func RunSequentially(fs ...func() error) error { return nil } -// RunParallel runs the given functions in parallel, +// Parallel runs the given functions in parallel, // It waits for all functions to finish and returns the first error encountered. -func RunParallel(fs ...func() error) func() error { +func Parallel(fs ...func() error) func() error { return func() error { g := errgroup.Group{} for _, f := range fs { diff --git a/utils/run/run_test.go b/utils/run/run_test.go new file mode 100644 index 000000000..07d2d3994 --- /dev/null +++ b/utils/run/run_test.go @@ -0,0 +1,171 @@ +package run_test + +import ( + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/navidrome/navidrome/utils/run" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestRun(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Run Suite") +} + +var _ = Describe("Sequentially", func() { + It("should return nil if no functions are provided", func() { + err := run.Sequentially() + Expect(err).To(BeNil()) + }) + + It("should return nil if all functions succeed", func() { + err := run.Sequentially( + func() error { return nil }, + func() error { return nil }, + ) + Expect(err).To(BeNil()) + }) + + It("should return the error from the first failing function", func() { + expectedErr := errors.New("error in function 2") + err := run.Sequentially( + func() error { return nil }, + func() error { return expectedErr }, + func() error { return errors.New("error in function 3") }, + ) + Expect(err).To(Equal(expectedErr)) + }) + + It("should not run functions after the first failing function", func() { + expectedErr := errors.New("error in function 1") + var runCount int + err := run.Sequentially( + func() error { runCount++; return expectedErr }, + func() error { runCount++; return nil }, + ) + Expect(err).To(Equal(expectedErr)) + Expect(runCount).To(Equal(1)) + }) +}) + +var _ = Describe("Parallel", func() { + It("should return a function that returns nil if no functions are provided", func() { + parallelFunc := run.Parallel() + err := parallelFunc() + Expect(err).To(BeNil()) + }) + + It("should return a function that returns nil if all functions succeed", func() { + parallelFunc := run.Parallel( + func() error { return nil }, + func() error { return nil }, + func() error { return nil }, + ) + err := parallelFunc() + Expect(err).To(BeNil()) + }) + + It("should return the first error encountered when functions fail", func() { + expectedErr := errors.New("parallel error") + parallelFunc := run.Parallel( + func() error { return nil }, + func() error { return expectedErr }, + func() error { return errors.New("another error") }, + ) + err := parallelFunc() + Expect(err).To(HaveOccurred()) + // Note: We can't guarantee which error will be returned first in parallel execution + // but we can ensure an error is returned + }) + + It("should run all functions in parallel", func() { + var runCount atomic.Int32 + sync := make(chan struct{}) + + parallelFunc := run.Parallel( + func() error { + runCount.Add(1) + <-sync + runCount.Add(-1) + return nil + }, + func() error { + runCount.Add(1) + <-sync + runCount.Add(-1) + return nil + }, + func() error { + runCount.Add(1) + <-sync + runCount.Add(-1) + return nil + }, + ) + + // Run the parallel function in a goroutine + go func() { + Expect(parallelFunc()).To(Succeed()) + }() + + // Wait for all functions to start running + Eventually(func() int32 { return runCount.Load() }).Should(Equal(int32(3))) + + // Release the functions to complete + close(sync) + + // Wait for all functions to finish + Eventually(func() int32 { return runCount.Load() }).Should(Equal(int32(0))) + }) + + It("should wait for all functions to complete before returning", func() { + var completedCount atomic.Int32 + + parallelFunc := run.Parallel( + func() error { + completedCount.Add(1) + return nil + }, + func() error { + completedCount.Add(1) + return nil + }, + func() error { + completedCount.Add(1) + return nil + }, + ) + + Expect(parallelFunc()).To(Succeed()) + Expect(completedCount.Load()).To(Equal(int32(3))) + }) + + It("should return an error even if other functions are still running", func() { + expectedErr := errors.New("fast error") + var slowFunctionCompleted bool + + parallelFunc := run.Parallel( + func() error { + return expectedErr // Return error immediately + }, + func() error { + time.Sleep(50 * time.Millisecond) // Slow function + slowFunctionCompleted = true + return nil + }, + ) + + start := time.Now() + err := parallelFunc() + duration := time.Since(start) + + Expect(err).To(HaveOccurred()) + // Should wait for all functions to complete, even if one fails early + Expect(duration).To(BeNumerically(">=", 50*time.Millisecond)) + Expect(slowFunctionCompleted).To(BeTrue()) + }) +}) diff --git a/utils/singleton/singleton.go b/utils/singleton/singleton.go index 7f5c6a4e0..1066ae610 100644 --- a/utils/singleton/singleton.go +++ b/utils/singleton/singleton.go @@ -9,36 +9,61 @@ import ( ) var ( - instances = make(map[string]any) + instances = map[string]interface{}{} + pending = map[string]chan struct{}{} lock sync.RWMutex ) -// GetInstance returns an existing instance of object. If it is not yet created, calls `constructor`, stores the -// result for future calls and returns it func GetInstance[T any](constructor func() T) T { var v T name := reflect.TypeOf(v).String() - v, available := func() (T, bool) { + // First check with read lock + lock.RLock() + if instance, ok := instances[name]; ok { + defer lock.RUnlock() + return instance.(T) + } + lock.RUnlock() + + // Now check if someone is already creating this type + lock.Lock() + + // Check again with the write lock - someone might have created it + if instance, ok := instances[name]; ok { + lock.Unlock() + return instance.(T) + } + + // Check if creation is pending + wait, isPending := pending[name] + if !isPending { + // We'll be the one creating it + pending[name] = make(chan struct{}) + wait = pending[name] + } + lock.Unlock() + + // If someone else is creating it, wait for them + if isPending { + <-wait // Wait for creation to complete + + // Now it should be in the instances map lock.RLock() defer lock.RUnlock() - v, available := instances[name].(T) - return v, available - }() - - if available { - return v + return instances[name].(T) } + // We're responsible for creating the instance + newInstance := constructor() + + // Store it and signal other goroutines lock.Lock() - defer lock.Unlock() - v, available = instances[name].(T) - if available { - return v - } + instances[name] = newInstance + close(wait) // Signal that creation is complete + delete(pending, name) // Clean up + log.Trace("Created new singleton", "type", name, "instance", fmt.Sprintf("%+v", newInstance)) + lock.Unlock() - v = constructor() - log.Trace("Created new singleton", "type", name, "instance", fmt.Sprintf("%+v", v)) - instances[name] = v - return v + return newInstance }