Compare commits

..

18 Commits

Author SHA1 Message Date
Deluan
c7d7ec306e fix(mcp-agent): stream native mcp-server stderr to logs
The previous implementation buffered stderr from the native mcp-server process and only logged the full buffer content when the process exited. This prevented real-time viewing of logs from the server.

This change modifies the native process startup logic (`startProcess_locked`) to use `cmd.StderrPipe()` instead of assigning `cmd.Stderr` to a buffer. A separate goroutine is now launched within the process monitoring goroutine. This new goroutine uses a `bufio.Scanner` to continuously read lines from the stderr pipe and logs them using the Navidrome logger (`log.Info`) with an `[MCP-SERVER]` prefix.

This ensures logs from the native mcp-server appear in Navidrome's logs immediately as they are written.

(Note: Also includes update to McpServerPath constant to point to the native binary.)

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-19 20:53:10 -04:00
Deluan
bcc3643c81 chore: add debug logging to mcp-server
Added debug logging throughout the mcp-server components using the standard Go `log` package. All log messages are prefixed with `[MCP]` for easy identification.

This includes logging in: main function (startup, CLI execution, registration, serve loop), Tool handlers, Native and WASM fetcher implementations, Wikipedia, Wikidata, and DBpedia data fetching functions

Replaced previous `println` statements with `log.Println` or `log.Printf`.
2025-04-19 19:37:32 -04:00
Deluan
97b101685e perf: pre-compile WASM module for MCP agent
Modified the MCP agent constructor to pre-compile the WASM module when detected. This shifts the costly compilation step out of the first API request path.

The `MCPWasm` implementation now stores the `wazero.CompiledModule` provided by the constructor and uses it directly for instantiation via `runtime.InstantiateModule()` when the agent is first used or restarted. This significantly speeds up the initialization during the first request.

Updated tests and cleanup logic to accommodate the shared nature of the pre-compiled module.
2025-04-19 19:23:23 -04:00
Deluan
8660cb4fff refactor: centralize MCP agent method logic and cleanup comments
Centralized the argument preparation for GetArtistBiography and GetArtistURL within the main MCPAgent struct. Added callMCPTool to the mcpImplementation interface and removed the redundant GetArtist* methods from the native and WASM implementations.

Removed the embedded agents.Artist*Retriever interfaces from mcpImplementation as MCPAgent now directly fulfills these.

Also removed various redundant comments and leftover commented-out code from the agent, implementation, and test files.
2025-04-19 19:11:34 -04:00
Deluan
ae93e555c9 feat: refactor MCP agent to support native and WASM implementations
Refactored the MCPAgent to delegate core functionality to separate implementations for native process and WASM execution.

Introduced an `mcpImplementation` interface (`MCPNative`, `MCPWasm`) to abstract the underlying execution method. The main `MCPAgent` now holds an instance of this interface, selected by the `mcpConstructor` based on the `McpServerPath` (native executable or `.wasm` file).

Shared Wazero resources (runtime, cache, WASI, host functions) are now initialized once in the constructor and passed to the `MCPWasm` implementation, improving resource management and potentially startup performance for WASM modules.

Updated tests (`mcp_agent_test.go`) to use a new testing constructor (`NewAgentForTesting`) which injects a mock client into the appropriate implementation. Assertions were adjusted to reflect the refactored error handling and structure. Also updated the `McpServerPath` to use a relative path.
2025-04-19 19:05:15 -04:00
Deluan
2f71516dde fix: update MCP server path for agent initialization
Change the MCP server path in MCPAgent to point to the correct relative directory for the WASM file. This adjustment ensures proper initialization and access to the server resources, aligning with recent enhancements in the MCPAgent's handling of server types.
2025-04-19 18:43:50 -04:00
Deluan
73da7550d6 refactor: separate native and WASM process handling in MCPAgent
- Moved the native process handling logic from mcp_agent.go to a new file mcp_process_native.go for better organization.
- Introduced a new file mcp_host_functions.go to define and register host functions for WASM modules.
- Updated MCPAgent to ensure proper initialization and cleanup of both native and WASM processes, enhancing code clarity and maintainability.
- Added comments to clarify the purpose of changes and ensure future developers understand the structure.
2025-04-19 15:22:15 -04:00
Deluan
674129a34b fix: update MCP server path for agent initialization
Change the MCP server path in MCPAgent from a WASM file to a directory. This adjustment aligns with recent enhancements to the MCPAgent's handling of server types, ensuring proper initialization and access to the server resources.
2025-04-19 14:48:18 -04:00
Deluan
fb0714562d feat: grant filesystem access for WASM modules in MCPAgent
Enhance the MCPAgent's WASM module initialization by granting access to the host filesystem. This is necessary for DNS lookups and other operations that may depend on filesystem access. Added comments to highlight the security implications of this change and the need for potential restrictions in the future.
2025-04-19 14:46:54 -04:00
Deluan
6b89f7ab63 feat: integrate Wazero for WASM support in MCPAgent
Enhance MCPAgent to support both native executables and WASM modules using Wazero. This includes:
- Adding Wazero dependencies in go.mod and go.sum.
- Modifying MCPAgent to initialize a shared Wazero runtime and compile/load WASM modules.
- Implementing cleanup logic for WASM resources.
- Updating the process initialization to handle both native and WASM paths.

This change improves the flexibility of the MCPAgent in handling different server types.
2025-04-19 14:28:55 -04:00
Deluan
c548168503 fix: handle error messages returned in MCP tool response content
Modify callMCPTool helper to check if the text content returned by\nthe MCP server starts with 'handler returned an error:'.\n\nIf it does, log a warning and return agents.ErrNotFound, treating\nthe error signaled by the external tool as if the data was not found.\nAdded test cases to verify this behavior.
2025-04-19 14:01:51 -04:00
Deluan
8ebefe4065 refactor: DRY up MCPAgent implementation
Refactor the MCPAgent to reduce code duplication.\n\n- Consolidate GetArtistBiographyArgs and GetArtistURLArgs into a single\n  ArtistArgs struct.\n- Extract common logic (initialization check, locking, tool calling,\n  error handling, response validation) into a private callMCPTool helper method.\n- Simplify GetArtistBiography and GetArtistURL to delegate to callMCPTool.\n- Update tests to use the consolidated ArtistArgs struct.\n- Correct mutex locking in ensureClientInitialized to prevent race conditions.
2025-04-19 13:04:41 -04:00
Deluan
6e59060a01 fix: prevent double initialization of agents
Modify the createAgents function to call each agent constructor only once.\n\nPreviously, the constructor (init(ds)) was called first to check if the\nagent could be initialized, and then called again when appending the agent\nto the result slice. This caused unnecessary work and duplicate log messages.\n\nThe fix stores the result of the first call in the 'agent' variable and\nreuses this instance when appending, ensuring each constructor runs only once.
2025-04-19 13:01:41 -04:00
Deluan
be9e10db37 test: add mock-based tests for MCPAgent
Implement unit tests for MCPAgent using a mocked MCP client.\n\n- Define an mcpClient interface and a mock implementation in the test file.\n- Modify MCPAgent to use the interface and add an exported ClientOverride field\n  to allow injecting the mock during tests.\n- Export necessary constants and argument structs from the agent package.\n- Add test cases covering success, tool errors, empty responses, and pipe errors\n  for both GetArtistBiography and GetArtistURL.\n- Fix agent logic to handle empty TextContent in responses correctly.\n- Remove previous placeholder tests and unreliable initialization test.
2025-04-19 12:56:22 -04:00
Deluan
9c20520d59 feat: implement GetArtistURL in MCP agent
Add support for retrieving artist URLs via the MCP agent.\n\n- Implement the agents.ArtistURLRetriever interface.\n- Add the GetArtistURL method, which calls the 'get_artist_url'\n  tool on the external MCP server.\n- Define the necessary constants and argument struct.\n\nThis mirrors the existing implementation for GetArtistBiography, reusing\nthe persistent process and client logic.
2025-04-19 12:48:27 -04:00
Deluan
8b754a7c73 feat: implement MCP agent process auto-restart
Modify the MCPAgent to automatically attempt restarting the external\nserver process if it detects the process has exited.\n\n- Replaced sync.Once with mutex-protected checks (a.client == nil) to allow\n  re-initialization.\n- The monitoring goroutine now cleans up agent state (nils client, cmd, stdin)\n  upon process exit, signaling the need for restart.\n- ensureClientInitialized now attempts to start/initialize if the client is nil.\n- GetArtistBiography checks client validity again after locking to handle race\n  conditions where the process might die just after initialization check.
2025-04-19 12:45:41 -04:00
Deluan
8326a20eda refactor: keep MCP agent server process running
Refactor MCPAgent to maintain a persistent external server process.\n\nInstead of starting and stopping the MCP server for each request, the agent\nnow uses sync.Once for lazy initialization on the first call.\nIt stores the running exec.Cmd, stdio pipes, and the mcp.Client\nin the agent struct.\n\nA sync.Mutex protects concurrent access to the client/pipes.\nA goroutine monitors the process using cmd.Wait() and logs if it exits\nunexpectedly.\n\nThis avoids the overhead of process creation/destruction on every\nmetadata retrieval request.
2025-04-19 12:04:39 -04:00
Deluan
51567a0bdf feat: add proof-of-concept MCP agent
Add a new agent implementation MCPAgent in core/agents/mcp.\n\nThis agent interacts with an external Model Context Protocol (MCP) server\nusing the github.com/metoro-io/mcp-golang library via stdio.\n\nIt currently implements the ArtistBiographyRetriever interface to fetch\nbiographies by calling the 'get_artist_biography' tool on the MCP server.\nThe path to the MCP server executable is hardcoded for this PoC.\n\nIncludes basic Ginkgo test setup for the new agent.
2025-04-19 11:59:34 -04:00
469 changed files with 7500 additions and 54482 deletions

View File

@@ -1,53 +0,0 @@
# Navidrome Code Guidelines
This is a music streaming server written in Go with a React frontend. The application manages music libraries, provides streaming capabilities, and offers various features like artist information, artwork handling, and external service integrations.
## Code Standards
### Backend (Go)
- Follow standard Go conventions and idioms
- Use context propagation for cancellation signals
- Write unit tests for new functionality using Ginkgo/Gomega
- Use mutex appropriately for concurrent operations
- Implement interfaces for dependencies to facilitate testing
### Frontend (React)
- Use functional components with hooks
- Follow React best practices for state management
- Implement PropTypes for component properties
- Prefer using React-Admin and Material-UI components
- Icons should be imported from `react-icons` only
- Follow existing patterns for API interaction
## Repository Structure
- `core/`: Server-side business logic (artwork handling, playback, etc.)
- `ui/`: React frontend components
- `model/`: Data models and repository interfaces
- `server/`: API endpoints and server implementation
- `utils/`: Shared utility functions
- `persistence/`: Database access layer
- `scanner/`: Music library scanning functionality
## Key Guidelines
1. Maintain cache management patterns for performance
2. Follow the existing concurrency patterns (mutex, atomic)
3. Use the testing framework appropriately (Ginkgo/Gomega for Go)
4. Keep UI components focused and reusable
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, 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
- 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

View File

@@ -1,38 +0,0 @@
### Description
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
### Related Issues
<!-- List any related issues, e.g., "Fixes #123" or "Related to #456". -->
### 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 projects 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
<!-- Describe the steps to test your changes. Include setup, commands, and expected results. -->
### Screenshots / Demos (if applicable)
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
### Additional Notes
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? -->
<!--
**Tips for Contributors:**
- Be concise but thorough.
- If your PR is large, consider breaking it into smaller PRs.
- Tag the maintainer if you need a prompt review.
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
-->

View File

@@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.1.1-1"
CROSS_TAGLIB_VERSION: "2.0.2-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@v8
uses: golangci/golangci-lint-action@v7
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$' | grep -v '.pb.go$'`
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$'`
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
run: |

11
.gitignore vendored
View File

@@ -5,7 +5,6 @@
/navidrome
/iTunes*.xml
/tmp
/bin
data/*
vendor/*/
wiki
@@ -24,11 +23,5 @@ music
docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-*
AGENTS.md
.github/prompts
.github/instructions
.github/git-commit-instructions.md
*.exe
*.test
*.wasm
navidrome-master
*.exe

View File

@@ -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.19 AS xx-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 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.19 AS taglib-build
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.21 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-1
ARG CROSS_TAGLIB_VERSION=2.0.2-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
RUN <<EOT
@@ -120,7 +120,7 @@ COPY --from=build /out /
########################################################################################################################
### Build Final Image
FROM public.ecr.aws/docker/library/alpine:3.19 AS final
FROM public.ecr.aws/docker/library/alpine:3.21 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"

View File

@@ -15,11 +15,11 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
CROSS_TAGLIB_VERSION ?= 2.0.2-1
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@(cd ./ui && npm ci)
.PHONY: setup
@@ -36,9 +36,8 @@ watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=netgo -notify ./...
.PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests
go test -tags netgo $(PKG)
go test -tags netgo ./...
.PHONY: test
testrace: ##@Development Run Go tests with race detector
@@ -46,15 +45,11 @@ testrace: ##@Development Run Go tests with race detector
.PHONY: test
testall: testrace ##@Development Run Go and JS tests
@(cd ./ui && npm run test)
@(cd ./ui && npm run test:ci)
.PHONY: testall
install-golangci-lint: ##@Development Install golangci-lint if not present
@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
PATH=$$PATH:./bin golangci-lint run -v --timeout 5m
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
@@ -64,7 +59,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$$ | grep -v .pb.go$$`
@go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$`
@go mod tidy
.PHONY: format
@@ -153,20 +148,6 @@ 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=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<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
@@ -175,10 +156,10 @@ package: docker-build ##@Cross_Compilation Create binaries and packages for ALL
get-music: ##@Development Download some free music from Navidrome's demo instance
mkdir -p music
( cd music; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music
@@ -235,24 +216,6 @@ deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
# Generate Go code from plugins/api/api.proto
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
go generate ./plugins/...
.PHONY: plugin-gen
plugin-examples: check_go_env ##@Development Build all example plugins
$(MAKE) -C plugins/examples clean all
.PHONY: plugin-examples
plugin-clean: check_go_env ##@Development Clean all plugins
$(MAKE) -C plugins/examples clean
$(MAKE) -C plugins/testdata clean
.PHONY: plugin-clean
plugin-tests: check_go_env ##@Development Build all test plugins
$(MAKE) -C plugins/testdata clean all
.PHONY: plugin-tests
.DEFAULT_GOAL := help
HELP_FUN = \

View File

@@ -8,7 +8,6 @@ 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"
)
@@ -83,29 +82,6 @@ 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

View File

@@ -7,7 +7,6 @@ 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"
@@ -149,7 +148,4 @@ func init() {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})
conf.AddHook(func() {
log.Debug("TagLib version", "version", Version())
})
}

View File

@@ -82,15 +82,15 @@ var _ = Describe("Extractor", func() {
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
Expect(m.HasPicture).To(BeFalse())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
@@ -98,7 +98,7 @@ var _ = Describe("Extractor", func() {
m := mds[file]
Expect(m.HasPicture).To(Equal(image))
Expect(m.HasPicture).To(BeFalse())
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
@@ -168,24 +168,24 @@ var _ = Describe("Extractor", func() {
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, false),
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true),
)
// Skip these tests when running as root

View File

@@ -225,25 +225,18 @@ char has_cover(const TagLib::FileRef f) {
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
hasCover = !opusFile->tag()->pictureList().isEmpty();
}
// ----- WMA
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
const TagLib::ASF::Tag *tag{asfFile->tag()};
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
}
// ----- WAV
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
if (wavFile->hasID3v2Tag()) {
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- AIFF
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
if (aiffFile->hasID3v2Tag()) {
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
hasCover = !frameListMap["APIC"].isEmpty();
}
}
// ----- WMA
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
const TagLib::ASF::Tag *tag{ asfFile->tag() };
hasCover = tag && asfFile->tag()->attributeListMap().contains("WM/Picture");
}
return hasCover;
}

View File

@@ -1,17 +0,0 @@
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")
}

View File

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

View File

@@ -1,193 +0,0 @@
package cmd
import (
"io"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/spf13/cobra"
)
var _ = Describe("Plugin CLI Commands", func() {
var tempDir string
var cmd *cobra.Command
var stdOut *os.File
var origStdout *os.File
var outReader *os.File
// Helper to create a test plugin with the given name and details
createTestPlugin := func(name, author, version string, capabilities []string) string {
pluginDir := filepath.Join(tempDir, name)
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
// Create a properly formatted capabilities JSON array
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
manifest := `{
"name": "` + name + `",
"author": "` + author + `",
"version": "` + version + `",
"description": "Plugin for testing",
"website": "https://test.navidrome.org/` + name + `",
"capabilities": [` + capabilitiesJSON + `],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
return pluginDir
}
// Helper to execute a command and return captured output
captureOutput := func(reader io.Reader) string {
stdOut.Close()
outputBytes, err := io.ReadAll(reader)
Expect(err).NotTo(HaveOccurred())
return string(outputBytes)
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tempDir = GinkgoT().TempDir()
// Setup config
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tempDir
// Create a command for testing
cmd = &cobra.Command{Use: "test"}
// Setup stdout capture
origStdout = os.Stdout
var err error
outReader, stdOut, err = os.Pipe()
Expect(err).NotTo(HaveOccurred())
os.Stdout = stdOut
DeferCleanup(func() {
os.Stdout = origStdout
})
})
AfterEach(func() {
os.Stdout = origStdout
if stdOut != nil {
stdOut.Close()
}
if outReader != nil {
outReader.Close()
}
})
Describe("Plugin list command", func() {
It("should list installed plugins", func() {
// Create test plugins
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
// Execute command
pluginList(cmd, []string{})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("plugin1"))
Expect(output).To(ContainSubstring("Test Author"))
Expect(output).To(ContainSubstring("1.0.0"))
Expect(output).To(ContainSubstring("MetadataAgent"))
Expect(output).To(ContainSubstring("plugin2"))
Expect(output).To(ContainSubstring("Another Author"))
Expect(output).To(ContainSubstring("2.1.0"))
Expect(output).To(ContainSubstring("Scrobbler"))
})
})
Describe("Plugin info command", func() {
It("should display information about an installed plugin", func() {
// Create test plugin with multiple capabilities
createTestPlugin("test-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent", "Scrobbler"})
// Execute command
pluginInfo(cmd, []string{"test-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Name: test-plugin"))
Expect(output).To(ContainSubstring("Author: Test Author"))
Expect(output).To(ContainSubstring("Version: 1.0.0"))
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
})
})
Describe("Plugin remove command", func() {
It("should remove a regular plugin directory", func() {
// Create test plugin
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
[]string{"MetadataAgent"})
// Execute command
pluginRemove(cmd, []string{"regular-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
// Verify directory is actually removed
_, err := os.Stat(pluginDir)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("should remove only the symlink for a development plugin", func() {
// Create a real source directory
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
manifest := `{
"name": "dev-plugin",
"author": "Dev Author",
"version": "0.1.0",
"description": "Development plugin for testing",
"website": "https://test.navidrome.org/dev-plugin",
"capabilities": ["Scrobbler"],
"permissions": {}
}`
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
// Create a dummy WASM file
wasmContent := []byte("dummy wasm content for testing")
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
// Create a symlink in the plugins directory
symlinkPath := filepath.Join(tempDir, "dev-plugin")
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
// Execute command
pluginRemove(cmd, []string{"dev-plugin"})
// Verify output
output := captureOutput(outReader)
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
Expect(output).To(ContainSubstring("target directory preserved"))
// Verify the symlink is removed but source directory exists
_, err := os.Lstat(symlinkPath)
Expect(os.IsNotExist(err)).To(BeTrue())
_, err = os.Stat(sourceDir)
Expect(err).NotTo(HaveOccurred())
})
})
})

View File

@@ -82,9 +82,8 @@ 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 {
@@ -148,7 +147,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)
@@ -173,7 +172,6 @@ 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()
@@ -192,7 +190,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 {
s := CreateScanner(ctx)
scanner := CreateScanner(ctx)
switch {
case fullScanRequired == "1":
log.Warn(ctx, "Full scan required after migration")
@@ -206,7 +204,7 @@ func runInitialScan(ctx context.Context) func() error {
log.Info("Executing initial scan")
}
_, err = s.ScanAll(ctx, fullScanRequired == "1")
_, err = scanner.ScanAll(ctx, fullScanRequired == "1")
if err != nil {
log.Error(ctx, "Scan failed", err)
} else {
@@ -245,7 +243,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)
@@ -273,7 +271,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
@@ -327,22 +325,6 @@ 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() {

View File

@@ -6,6 +6,8 @@ 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"
@@ -68,7 +70,7 @@ func runScanner(ctx context.Context) {
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
progress, err := scanner.CallScan(ctx, ds, artwork.NoopCacheWarmer(), pls, metrics.NewNoopInstance(), fullScan)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}

View File

@@ -22,7 +22,6 @@ 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"
@@ -67,9 +66,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
agentsAgents := agents.GetAgents(dataStore)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
@@ -80,10 +77,11 @@ 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, manager)
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
return router
}
@@ -92,9 +90,7 @@ func CreatePublicRouter() *public.Router {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
agentsAgents := agents.GetAgents(dataStore)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
@@ -129,7 +125,7 @@ func CreateInsights() metrics.Insights {
func CreatePrometheus() metrics.Metrics {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
metricsMetrics := metrics.NewPrometheusInstance(dataStore)
return metricsMetrics
}
@@ -138,14 +134,13 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
agentsAgents := agents.GetAgents(dataStore)
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
}
@@ -155,14 +150,13 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
manager := plugins.GetManager(dataStore, metricsMetrics)
agentsAgents := agents.GetAgents(dataStore, manager)
agentsAgents := agents.GetAgents(dataStore)
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
@@ -175,20 +169,6 @@ 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, 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
}
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)

View File

@@ -7,17 +7,14 @@ 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"
@@ -39,11 +36,8 @@ var allProviders = wire.NewSet(
events.GetBroker,
scanner.New,
scanner.NewWatcher,
plugins.GetManager,
metrics.GetPrometheusInstance,
metrics.NewPrometheusInstance,
db.Db,
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
)
func CreateDataStore() model.DataStore {
@@ -117,15 +111,3 @@ 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
}

View File

@@ -14,7 +14,7 @@ import (
"github.com/kr/pretty"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/chain"
"github.com/robfig/cron/v3"
"github.com/spf13/viper"
)
@@ -66,21 +66,18 @@ type configOptions struct {
CoverArtPriority string
CoverJpegQuality int
ArtistArtPriority string
LyricsPriority string
EnableGravatar bool
EnableFavourites bool
EnableStarRating bool
EnableUserEditing bool
EnableSharing bool
ShareURL string
DefaultShareExpiration time.Duration
DefaultDownloadableShare bool
DefaultTheme string
DefaultLanguage string
DefaultUIVolume int
EnableReplayGain bool
EnableCoverAnimation bool
EnableNowPlaying bool
GATrackingID string
EnableLogRedacting bool
AuthRequestLimit int
@@ -88,35 +85,32 @@ type configOptions struct {
PasswordEncryptionKey string
ReverseProxyUserHeader string
ReverseProxyWhitelist string
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
HTTPSecurityHeaders secureOptions
Prometheus prometheusOptions
Scanner scannerOptions
Jukebox jukeboxOptions
Backup backupOptions
PID pidOptions
Inspect inspectOptions
Subsonic subsonicOptions
Agents string
LastFM lastfmOptions
Spotify spotifyOptions
ListenBrainz listenBrainzOptions
Tags map[string]TagConf
// 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
DevActivityPanel bool
DevActivityPanelUpdateRate time.Duration
DevSidebarPlaylists bool
DevEnableBufferedScrobble bool
DevShowArtistPage bool
DevUIShowConfig bool
DevNewEventStream bool
DevOffsetOptimize int
DevArtworkMaxRequests int
DevArtworkThrottleBacklogLimit int
@@ -127,8 +121,6 @@ type configOptions struct {
DevScannerThreads uint
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
}
type scannerOptions struct {
@@ -140,8 +132,6 @@ type scannerOptions struct {
ArtistJoiner string
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 {
@@ -152,20 +142,19 @@ type subsonicOptions struct {
}
type TagConf struct {
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"`
Ignore bool `yaml:"ignore"`
Aliases []string `yaml:"aliases"`
Type string `yaml:"type"`
MaxLength int `yaml:"maxLength"`
Split []string `yaml:"split"`
Album bool `yaml:"album"`
}
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
Language string
ScrobbleFirstArtistOnly bool
Enabled bool
ApiKey string
Secret string
Language string
}
type spotifyOptions struct {
@@ -173,10 +162,6 @@ type spotifyOptions struct {
Secret string
}
type deezerOptions struct {
Enabled bool
}
type listenBrainzOptions struct {
Enabled bool
BaseURL string
@@ -219,12 +204,6 @@ type inspectOptions struct {
BacklogTimeout int
}
type pluginsOptions struct {
Enabled bool
Folder string
CacheSize string
}
var (
Server = &configOptions{}
hooks []func()
@@ -264,15 +243,6 @@ 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)
@@ -301,11 +271,10 @@ func Load(noConfigDump bool) {
log.SetLogSourceLine(Server.DevLogSourceLine)
log.SetRedacting(Server.EnableLogRedacting)
err = run.Sequentially(
err = chain.RunSequentially(
validateScanSchedule,
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
)
if err != nil {
os.Exit(1)
@@ -343,7 +312,6 @@ func Load(noConfigDump bool) {
}
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
// Call init hooks
for _, hook := range hooks {
@@ -393,7 +361,6 @@ 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 {
@@ -412,24 +379,6 @@ 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 = ""
@@ -469,7 +418,7 @@ func AddHook(hook func()) {
hooks = append(hooks, hook)
}
func setViperDefaults() {
func init() {
viper.SetDefault("musicfolder", filepath.Join(".", "music"))
viper.SetDefault("cachefolder", "")
viper.SetDefault("datafolder", ".")
@@ -505,11 +454,11 @@ func setViperDefaults() {
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 %f --input-ipc-server=%s")
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display --pause %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)
@@ -519,10 +468,8 @@ func setViperDefaults() {
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)
viper.SetDefault("defaultdownloadableshare", false)
viper.SetDefault("gatrackingid", "")
viper.SetDefault("enableinsightscollector", true)
@@ -530,15 +477,19 @@ func setViperDefaults() {
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)
@@ -547,36 +498,35 @@ func setViperDefaults() {
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
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,deezer")
viper.SetDefault("agents", "lastfm,spotify")
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("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)
@@ -585,10 +535,9 @@ func setViperDefaults() {
viper.SetDefault("devautologinusername", "")
viper.SetDefault("devactivitypanel", true)
viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond)
viper.SetDefault("devenablebufferedscrobble", true)
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)
@@ -599,12 +548,6 @@ func setViperDefaults() {
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) {

View File

@@ -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,10 +20,9 @@ var _ = Describe("Configuration", func() {
BeforeEach(func() {
// Reset viper configuration
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
ResetConf()
})
DescribeTable("should load configuration from",
@@ -31,17 +30,17 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
conf.InitConfig(filename)
InitConfig(filename)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
Load(true)
// Execute the format-specific assertions
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"}))
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"}))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
Expect(Server.ConfigFile).To(Equal(filename))
},
Entry("TOML format", "toml"),
Entry("YAML format", "yaml"),

View File

@@ -3,5 +3,3 @@ package conf
func ResetConf() {
Server = &configOptions{}
}
var SetViperDefaults = setViperDefaults

View File

@@ -14,9 +14,6 @@ const (
DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
InitialSetupFlagKey = "InitialSetup"
FullScanAfterMigrationFlagKey = "FullScanAfterMigration"
LastScanErrorKey = "LastScanError"
LastScanTypeKey = "LastScanType"
LastScanStartTimeKey = "LastScanStartTime"
UIAuthorizationHeader = "X-ND-Authorization"
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
@@ -115,12 +112,6 @@ const (
InsightsInitialDelay = 30 * time.Minute
)
const (
PurgeMissingNever = "never"
PurgeMissingAlways = "always"
PurgeMissingFull = "full"
)
var (
DefaultDownsamplingFormat = "opus"
DefaultTranscodings = []struct {

View File

@@ -2,9 +2,7 @@ package agents
import (
"context"
"slices"
"strings"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
@@ -15,156 +13,43 @@ import (
"github.com/navidrome/navidrome/utils/singleton"
)
// 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)
}
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
ds model.DataStore
agents []Interface
}
// GetAgents returns the singleton instance of Agents
func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
func GetAgents(ds model.DataStore) *Agents {
return singleton.GetInstance(func() *Agents {
return createAgents(ds, pluginLoader)
return createAgents(ds)
})
}
// 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),
func createAgents(ds model.DataStore) *Agents {
var order []string
if conf.Server.Agents != "" {
order = strings.Split(conf.Server.Agents, ",")
}
}
// 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}
}
// 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
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
}
}
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)
agent := init(ds)
if agent == nil {
log.Debug("Agent not available. Missing configuration?", "name", name)
continue
}
enabled = append(enabled, name)
res = append(res, agent)
}
return validNames
}
log.Debug("List of agents enabled", "names", enabled)
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
return &Agents{ds: ds, agents: res}
}
func (a *Agents) AgentName() string {
@@ -179,19 +64,15 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
return "", nil
}
start := time.Now()
for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistMBIDRetriever)
agent, ok := ag.(ArtistMBIDRetriever)
if !ok {
continue
}
mbid, err := retriever.GetArtistMBID(ctx, id, name)
mbid, err := agent.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
@@ -208,19 +89,15 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
return "", nil
}
start := time.Now()
for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistURLRetriever)
agent, ok := ag.(ArtistURLRetriever)
if !ok {
continue
}
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
url, err := agent.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
@@ -237,19 +114,15 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return "", nil
}
start := time.Now()
for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistBiographyRetriever)
agent, ok := ag.(ArtistBiographyRetriever)
if !ok {
continue
}
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
bio, err := agent.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
@@ -258,8 +131,6 @@ 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:
@@ -267,23 +138,16 @@ 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 _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistSimilarRetriever)
agent, ok := ag.(ArtistSimilarRetriever)
if !ok {
continue
}
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit)
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))
@@ -304,19 +168,15 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return nil, nil
}
start := time.Now()
for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistImageRetriever)
agent, ok := ag.(ArtistImageRetriever)
if !ok {
continue
}
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
images, err := agent.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
@@ -325,8 +185,6 @@ 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:
@@ -334,23 +192,16 @@ 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 _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistTopSongsRetriever)
agent, ok := ag.(ArtistTopSongsRetriever)
if !ok {
continue
}
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
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
@@ -364,19 +215,15 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
return nil, ErrNotFound
}
start := time.Now()
for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName)
if ag == nil {
continue
}
for _, ag := range a.agents {
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumInfoRetriever)
agent, ok := ag.(AlbumInfoRetriever)
if !ok {
continue
}
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
album, err := agent.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))
@@ -386,33 +233,6 @@ 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)
@@ -421,4 +241,3 @@ var _ ArtistSimilarRetriever = (*Agents)(nil)
var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
var _ AlbumImageRetriever = (*Agents)(nil)

View File

@@ -1,221 +0,0 @@
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))
})
})
})

View File

@@ -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,7 +20,6 @@ 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}
@@ -30,7 +29,7 @@ var _ = Describe("Agents", func() {
var ag *Agents
BeforeEach(func() {
conf.Server.Agents = ""
ag = createAgents(ds, nil)
ag = createAgents(ds)
})
It("calls the placeholder GetArtistImages", func() {
@@ -50,18 +49,12 @@ 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, nil)
ag = createAgents(ds)
Expect(ag.AgentName()).To(Equal("agents"))
})
It("does not register disabled agents", func() {
var ags []string
for _, name := range ag.getEnabledAgentNames() {
agent := ag.getAgent(name)
if agent != nil {
ags = append(ags, agent.AgentName())
}
}
ags := slice.Map(ag.agents, func(a Interface) string { return a.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"))
@@ -180,42 +173,6 @@ 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() {
@@ -242,7 +199,6 @@ 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",
@@ -250,7 +206,6 @@ 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))
@@ -262,14 +217,6 @@ 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() {
@@ -279,6 +226,18 @@ 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"))
})
@@ -374,6 +333,18 @@ 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
}
@@ -384,17 +355,3 @@ 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
}

View File

@@ -1,83 +0,0 @@
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)
}

View File

@@ -1,68 +0,0 @@
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())
}

View File

@@ -1,97 +0,0 @@
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)
}
})
}

View File

@@ -1,31 +0,0 @@
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"`
}

View File

@@ -1,38 +0,0 @@
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"))
})
})
})

View File

@@ -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,16 +40,11 @@ var (
ErrNotFound = errors.New("not found")
)
// AlbumInfoRetriever provides album info (no images)
// TODO Break up this interface in more specific methods, like artists
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)
}

View File

@@ -72,23 +72,16 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
return nil, err
}
return &agents.AlbumInfo{
response := agents.AlbumInfo{
Name: a.Name,
MBID: a.MBID,
Description: a.Description.Summary,
URL: a.URL,
}, 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
Images: make([]agents.ExternalImage, 0),
}
// 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
@@ -99,20 +92,23 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str
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
}
if _, exists := seenSizes[numericSize]; !exists {
images = append(images, agents.ExternalImage{
Size: numericSize,
URL: img.URL,
})
seenSizes[numericSize] = true
} else {
if _, exists := seenSizes[numericSize]; !exists {
response.Images = append(response.Images, agents.ExternalImage{
Size: numericSize,
URL: img.URL,
})
seenSizes[numericSize] = true
}
}
}
return images, nil
return &response, nil
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
@@ -283,21 +279,14 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
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 {
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(track),
artist: track.Artist,
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
@@ -323,7 +312,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(&s.MediaFile),
artist: s.Artist,
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
@@ -355,8 +344,6 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
@@ -364,8 +351,6 @@ func init() {
return nil
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a

View File

@@ -196,12 +196,6 @@ 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"}},
},
},
}
})
@@ -209,7 +203,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, 0)
err := agent.NowPlaying(ctx, "user-1", track)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
@@ -226,7 +220,7 @@ var _ = Describe("lastfmAgent", func() {
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
err := agent.NowPlaying(ctx, "user-2", track)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})
@@ -253,23 +247,6 @@ 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}
@@ -345,6 +322,24 @@ 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 <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
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"))
@@ -354,8 +349,9 @@ 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",
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{},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))

View File

@@ -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, position int) error {
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return errors.Join(err, scrobbler.ErrNotAuthorized)

View File

@@ -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, 0)
err := agent.NowPlaying(ctx, "user-1", track)
Expect(err).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
err := agent.NowPlaying(ctx, "user-2", track)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})

2
core/agents/mcp/mcp-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
mcp-server
*.wasm

View File

@@ -0,0 +1,17 @@
# MCP Server (Proof of Concept)
This directory contains the source code for the `mcp-server`, a simple server implementation used as a proof-of-concept (PoC) for the Navidrome Plugin/MCP agent system.
This server is designed to be compiled into a WebAssembly (WASM) module (`.wasm`) using the `wasip1` target.
## Compilation
To compile the server into a WASM module (`mcp-server.wasm`), navigate to this directory in your terminal and run the following command:
```bash
CGO_ENABLED=0 GOOS=wasip1 GOARCH=wasm go build -o mcp-server.wasm .
```
**Note:** This command compiles the WASM module _without_ the `netgo` tag. Networking operations (like HTTP requests) are expected to be handled by host functions provided by the embedding application (Navidrome's `MCPAgent`) rather than directly within the WASM module itself.
Place the resulting `mcp-server.wasm` file where the Navidrome `MCPAgent` expects it (currently configured via the `McpServerPath` constant in `core/agents/mcp/mcp_agent.go`).

View File

@@ -0,0 +1,172 @@
package main
import (
"context"
"encoding/json" // Reusing ErrNotFound from wikidata.go (implicitly via main)
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
)
const dbpediaEndpoint = "https://dbpedia.org/sparql"
// Default timeout for DBpedia requests
const defaultDbpediaTimeout = 20 * time.Second
// Can potentially reuse SparqlResult, SparqlBindings, SparqlValue from wikidata.go
// if the structure is identical. Assuming it is for now.
// GetArtistBioFromDBpedia queries DBpedia for an artist's abstract using their name.
func GetArtistBioFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
log.Printf("[MCP] Debug: GetArtistBioFromDBpedia called for name: %s", name)
if name == "" {
log.Printf("[MCP] Error: GetArtistBioFromDBpedia requires a name.")
return "", fmt.Errorf("name is required to query DBpedia by name")
}
// Escape name for SPARQL query literal
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
// SPARQL query using DBpedia ontology (dbo)
// Prefixes are recommended but can be omitted if endpoint resolves them.
// Searching case-insensitively on the label.
// Filtering for dbo:MusicalArtist or dbo:Band.
// Selecting the English abstract.
sparqlQuery := fmt.Sprintf(`
PREFIX dbo: <http://dbpedia.org/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
SELECT DISTINCT ?abstract WHERE {
?artist rdfs:label ?nameLabel .
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
# Ensure it's a musical artist or band
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
?artist dbo:abstract ?abstract .
FILTER(LANG(?abstract) = "en")
} LIMIT 1`, escapedName)
// Prepare and execute HTTP request
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "application/sparql-results+json") // DBpedia standard format
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: DBpedia Bio Request URL: %s", reqURL)
timeout := defaultDbpediaTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching from DBpedia with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for DBpedia bio request (name: '%s'): %v", name, err)
return "", fmt.Errorf("failed to execute DBpedia request: %w", err)
}
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: DBpedia bio query failed for name '%s' with status %d: %s", name, statusCode, string(bodyBytes))
return "", fmt.Errorf("DBpedia query failed with status %d: %s", statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: DBpedia bio query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
var result SparqlResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
// Try reading the raw body for debugging if JSON parsing fails
// (Seek back to the beginning might be needed if already read for error)
// For simplicity, just return the parsing error now.
log.Printf("[MCP] Error: Failed to decode DBpedia bio response for name '%s': %v", name, err)
return "", fmt.Errorf("failed to decode DBpedia response: %w", err)
}
// Extract the abstract
if len(result.Results.Bindings) > 0 {
if abstractVal, ok := result.Results.Bindings[0]["abstract"]; ok {
log.Printf("[MCP] Debug: Found DBpedia abstract for '%s'.", name)
return abstractVal.Value, nil
}
}
// Use the shared ErrNotFound
log.Printf("[MCP] Warn: No abstract found on DBpedia for name '%s'.", name)
return "", ErrNotFound
}
// GetArtistWikipediaURLFromDBpedia queries DBpedia for an artist's Wikipedia URL using their name.
func GetArtistWikipediaURLFromDBpedia(fetcher Fetcher, ctx context.Context, name string) (string, error) {
log.Printf("[MCP] Debug: GetArtistWikipediaURLFromDBpedia called for name: %s", name)
if name == "" {
log.Printf("[MCP] Error: GetArtistWikipediaURLFromDBpedia requires a name.")
return "", fmt.Errorf("name is required to query DBpedia by name for URL")
}
escapedName := strings.ReplaceAll(name, "\"", "\\\"")
// SPARQL query using foaf:isPrimaryTopicOf
sparqlQuery := fmt.Sprintf(`
PREFIX dbo: <http://dbpedia.org/ontology/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT DISTINCT ?wikiPage WHERE {
?artist rdfs:label ?nameLabel .
FILTER(LCASE(STR(?nameLabel)) = LCASE("%s") && LANG(?nameLabel) = "en")
{ ?artist rdf:type dbo:MusicalArtist } UNION { ?artist rdf:type dbo:Band }
?artist foaf:isPrimaryTopicOf ?wikiPage .
# Ensure it links to the English Wikipedia
FILTER(STRSTARTS(STR(?wikiPage), "https://en.wikipedia.org/"))
} LIMIT 1`, escapedName)
// Prepare and execute HTTP request (similar structure to bio query)
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "application/sparql-results+json")
reqURL := fmt.Sprintf("%s?%s", dbpediaEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: DBpedia URL Request URL: %s", reqURL)
timeout := defaultDbpediaTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching DBpedia URL with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for DBpedia URL request (name: '%s'): %v", name, err)
return "", fmt.Errorf("failed to execute DBpedia URL request: %w", err)
}
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: DBpedia URL query failed for name '%s' with status %d: %s", name, statusCode, string(bodyBytes))
return "", fmt.Errorf("DBpedia URL query failed with status %d: %s", statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: DBpedia URL query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
var result SparqlResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
log.Printf("[MCP] Error: Failed to decode DBpedia URL response for name '%s': %v", name, err)
return "", fmt.Errorf("failed to decode DBpedia URL response: %w", err)
}
// Extract the URL
if len(result.Results.Bindings) > 0 {
if pageVal, ok := result.Results.Bindings[0]["wikiPage"]; ok {
log.Printf("[MCP] Debug: Found DBpedia Wikipedia URL for '%s': %s", name, pageVal.Value)
return pageVal.Value, nil
}
}
log.Printf("[MCP] Warn: No Wikipedia URL found on DBpedia for name '%s'.", name)
return "", ErrNotFound
}

View File

@@ -0,0 +1,16 @@
package main
import (
"context"
"time"
)
// Fetcher defines an interface for making HTTP requests, abstracting
// over native net/http and WASM host functions.
type Fetcher interface {
// Fetch performs an HTTP request.
// Returns the status code, response body, and any error encountered.
// Note: Implementations should aim to return the body even on non-2xx status codes
// if the body was successfully read, allowing callers to potentially inspect it.
Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error)
}

View File

@@ -0,0 +1,83 @@
//go:build !wasm
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"net/http"
"time"
)
type nativeFetcher struct {
// We could hold a shared client, but creating one per request
// with the specific timeout is simpler for this adapter.
}
// Ensure nativeFetcher implements Fetcher
var _ Fetcher = (*nativeFetcher)(nil)
// NewFetcher creates the default native HTTP fetcher.
func NewFetcher() Fetcher {
log.Println("[MCP] Debug: Using Native HTTP fetcher")
return &nativeFetcher{}
}
func (nf *nativeFetcher) Fetch(ctx context.Context, method, urlStr string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
log.Printf("[MCP] Debug: Native Fetch: Method=%s, URL=%s, Timeout=%v", method, urlStr, timeout)
// Create a client with the specific timeout for this request
client := &http.Client{Timeout: timeout}
var bodyReader io.Reader
if requestBody != nil {
bodyReader = bytes.NewReader(requestBody)
}
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
if err != nil {
log.Printf("[MCP] Error: Native Fetch failed to create request: %v", err)
return 0, nil, fmt.Errorf("failed to create native request: %w", err)
}
// Set headers consistent with previous direct client usage
req.Header.Set("Accept", "application/sparql-results+json, application/json")
// Note: Specific User-Agent was set per call site previously, might need adjustment
// if different user agents are desired per service.
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (Native Client)")
log.Printf("[MCP] Debug: Native Fetch executing request...")
resp, err := client.Do(req)
if err != nil {
// Let context cancellation errors pass through
if ctx.Err() != nil {
log.Printf("[MCP] Debug: Native Fetch context cancelled: %v", ctx.Err())
return 0, nil, ctx.Err()
}
log.Printf("[MCP] Error: Native Fetch HTTP request failed: %v", err)
return 0, nil, fmt.Errorf("native HTTP request failed: %w", err)
}
defer resp.Body.Close()
statusCode = resp.StatusCode
log.Printf("[MCP] Debug: Native Fetch received status code: %d", statusCode)
responseBodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
// Still return status code if body read fails
log.Printf("[MCP] Error: Native Fetch failed to read response body: %v", readErr)
return statusCode, nil, fmt.Errorf("failed to read native response body: %w", readErr)
}
responseBody = responseBodyBytes
log.Printf("[MCP] Debug: Native Fetch read %d bytes from response body", len(responseBodyBytes))
// Mimic behavior of returning body even on error status
if statusCode < 200 || statusCode >= 300 {
log.Printf("[MCP] Warn: Native Fetch request failed with status %d. Body: %s", statusCode, string(responseBody))
return statusCode, responseBody, fmt.Errorf("native request failed with status %d", statusCode)
}
log.Printf("[MCP] Debug: Native Fetch completed successfully.")
return statusCode, responseBody, nil
}

View File

@@ -0,0 +1,171 @@
//go:build wasm
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"unsafe"
)
// --- WASM Host Function Import --- (Copied from user prompt)
//go:wasmimport env http_fetch
//go:noescape
func http_fetch(
// Request details
urlPtr, urlLen uint32,
methodPtr, methodLen uint32,
bodyPtr, bodyLen uint32,
timeoutMillis uint32,
// Result pointers
resultStatusPtr uint32,
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
) uint32 // 0 on success, 1 on host error
// --- Go Wrapper for Host Function --- (Copied from user prompt)
const (
defaultResponseBodyCapacity = 1024 * 10 // 10 KB for response body
defaultResponseErrorCapacity = 1024 // 1 KB for error messages
)
// callHostHTTPFetch provides a Go-friendly interface to the http_fetch host function.
func callHostHTTPFetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
log.Printf("[MCP] Debug: WASM Fetch (Host Call): Method=%s, URL=%s, Timeout=%v", method, url, timeout)
// --- Prepare Input Pointers ---
urlPtr, urlLen := stringToPtr(url)
methodPtr, methodLen := stringToPtr(method)
bodyPtr, bodyLen := bytesToPtr(requestBody)
timeoutMillis := uint32(timeout.Milliseconds())
if timeoutMillis <= 0 {
timeoutMillis = 30000 // Default 30 seconds if 0 or negative
}
if timeout == 0 {
// Handle case where context might already be cancelled
select {
case <-ctx.Done():
log.Printf("[MCP] Debug: WASM Fetch context cancelled before host call: %v", ctx.Err())
return 0, nil, ctx.Err()
default:
}
}
// --- Prepare Output Buffers and Pointers ---
resultBodyBuffer := make([]byte, defaultResponseBodyCapacity)
resultErrorBuffer := make([]byte, defaultResponseErrorCapacity)
resultStatus := uint32(0)
resultBodyLen := uint32(0)
resultErrorLen := uint32(0)
resultStatusPtr := &resultStatus
resultBodyPtr, resultBodyCapacity := bytesToPtr(resultBodyBuffer)
resultBodyLenPtr := &resultBodyLen
resultErrorPtr, resultErrorCapacity := bytesToPtr(resultErrorBuffer)
resultErrorLenPtr := &resultErrorLen
// --- Call the Host Function ---
log.Printf("[MCP] Debug: WASM Fetch calling host function http_fetch...")
hostReturnCode := http_fetch(
urlPtr, urlLen,
methodPtr, methodLen,
bodyPtr, bodyLen,
timeoutMillis,
uint32(uintptr(unsafe.Pointer(resultStatusPtr))),
resultBodyPtr, resultBodyCapacity, uint32(uintptr(unsafe.Pointer(resultBodyLenPtr))),
resultErrorPtr, resultErrorCapacity, uint32(uintptr(unsafe.Pointer(resultErrorLenPtr))),
)
log.Printf("[MCP] Debug: WASM Fetch host function returned code: %d", hostReturnCode)
// --- Process Results ---
if hostReturnCode != 0 {
err = errors.New("host function http_fetch failed internally")
log.Printf("[MCP] Error: WASM Fetch host function failed: %v", err)
return 0, nil, err
}
statusCode = int(resultStatus)
log.Printf("[MCP] Debug: WASM Fetch received status code from host: %d", statusCode)
if resultErrorLen > 0 {
actualErrorLen := min(resultErrorLen, resultErrorCapacity)
errMsg := string(resultErrorBuffer[:actualErrorLen])
err = errors.New(errMsg)
log.Printf("[MCP] Error: WASM Fetch received error from host: %s", errMsg)
return statusCode, nil, err
}
if resultBodyLen > 0 {
actualBodyLen := min(resultBodyLen, resultBodyCapacity)
responseBody = make([]byte, actualBodyLen)
copy(responseBody, resultBodyBuffer[:actualBodyLen])
log.Printf("[MCP] Debug: WASM Fetch received %d bytes from host body (reported size: %d)", actualBodyLen, resultBodyLen)
if resultBodyLen > resultBodyCapacity {
err = fmt.Errorf("response body truncated: received %d bytes, but actual size was %d", actualBodyLen, resultBodyLen)
log.Printf("[MCP] Warn: WASM Fetch %v", err)
return statusCode, responseBody, err // Return truncated body with error
}
log.Printf("[MCP] Debug: WASM Fetch completed successfully.")
return statusCode, responseBody, nil
}
log.Printf("[MCP] Debug: WASM Fetch completed successfully (no body, no error).")
return statusCode, nil, nil
}
// --- Pointer Helper Functions --- (Copied from user prompt)
func stringToPtr(s string) (ptr uint32, length uint32) {
if len(s) == 0 {
return 0, 0
}
// Use unsafe.StringData for potentially safer pointer access in modern Go
// Needs Go 1.20+
// return uint32(uintptr(unsafe.Pointer(unsafe.StringData(s)))), uint32(len(s))
// Fallback to slice conversion for broader compatibility / if StringData isn't available
buf := []byte(s)
return bytesToPtr(buf)
}
func bytesToPtr(b []byte) (ptr uint32, length uint32) {
if len(b) == 0 {
return 0, 0
}
// Use unsafe.SliceData for potentially safer pointer access in modern Go
// Needs Go 1.20+
// return uint32(uintptr(unsafe.Pointer(unsafe.SliceData(b)))), uint32(len(b))
// Fallback for broader compatibility
return uint32(uintptr(unsafe.Pointer(&b[0]))), uint32(len(b))
}
func min(a, b uint32) uint32 {
if a < b {
return a
}
return b
}
// --- WASM Fetcher Implementation ---
type wasmFetcher struct{}
// Ensure wasmFetcher implements Fetcher
var _ Fetcher = (*wasmFetcher)(nil)
// NewFetcher creates the WASM host function fetcher.
func NewFetcher() Fetcher {
log.Println("[MCP] Debug: Using WASM host fetcher")
return &wasmFetcher{}
}
func (wf *wasmFetcher) Fetch(ctx context.Context, method, url string, requestBody []byte, timeout time.Duration) (statusCode int, responseBody []byte, err error) {
// Directly call the wrapper which now contains logging
return callHostHTTPFetch(ctx, method, url, requestBody, timeout)
}

View File

@@ -0,0 +1,19 @@
module mcp-server
go 1.24.2
require github.com/metoro-io/mcp-golang v0.11.0
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/invopop/jsonschema v0.12.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,34 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
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 v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,289 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/url"
"os"
mcp_golang "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
)
type Content struct {
Title string `json:"title" jsonschema:"required,description=The title to submit"`
Description *string `json:"description" jsonschema:"description=The description to submit"`
}
type MyFunctionsArguments struct {
Submitter string `json:"submitter" jsonschema:"required,description=The name of the thing calling this tool (openai, google, claude, etc)"`
Content Content `json:"content" jsonschema:"required,description=The content of the message"`
}
type ArtistBiography struct {
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
}
type ArtistURLArgs struct {
ID string `json:"id" jsonschema:"required,description=The id of the artist"`
Name string `json:"name" jsonschema:"required,description=The name of the artist"`
MBID string `json:"mbid" jsonschema:"description=The mbid of the artist"`
}
func main() {
log.Println("[MCP] Starting mcp-server...")
done := make(chan struct{})
// Create the appropriate fetcher (native or WASM based on build tags)
log.Printf("[MCP] Debug: Creating fetcher...")
fetcher := NewFetcher()
log.Printf("[MCP] Debug: Fetcher created successfully.")
// --- Command Line Flag Handling ---
nameFlag := flag.String("name", "", "Artist name to query directly")
mbidFlag := flag.String("mbid", "", "Artist MBID to query directly")
flag.Parse()
if *nameFlag != "" || *mbidFlag != "" {
log.Printf("[MCP] Debug: Running tools directly via CLI flags (Name: '%s', MBID: '%s')", *nameFlag, *mbidFlag)
fmt.Println("--- Running Tools Directly ---")
// Call getArtistBiography
fmt.Printf("Calling get_artist_biography (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
if *mbidFlag == "" && *nameFlag == "" {
fmt.Println(" Error: --mbid or --name is required for get_artist_biography")
} else {
// Use context.Background for CLI calls
log.Printf("[MCP] Debug: CLI calling getArtistBiography...")
bio, bioErr := getArtistBiography(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
if bioErr != nil {
fmt.Printf(" Error: %v\n", bioErr)
log.Printf("[MCP] Error: CLI getArtistBiography failed: %v", bioErr)
} else {
fmt.Printf(" Result: %s\n", bio)
log.Printf("[MCP] Debug: CLI getArtistBiography succeeded.")
}
}
// Call getArtistURL
fmt.Printf("Calling get_artist_url (Name: '%s', MBID: '%s')...\n", *nameFlag, *mbidFlag)
if *mbidFlag == "" && *nameFlag == "" {
fmt.Println(" Error: --mbid or --name is required for get_artist_url")
} else {
log.Printf("[MCP] Debug: CLI calling getArtistURL...")
urlResult, urlErr := getArtistURL(fetcher, context.Background(), "cli", *nameFlag, *mbidFlag)
if urlErr != nil {
fmt.Printf(" Error: %v\n", urlErr)
log.Printf("[MCP] Error: CLI getArtistURL failed: %v", urlErr)
} else {
fmt.Printf(" Result: %s\n", urlResult)
log.Printf("[MCP] Debug: CLI getArtistURL succeeded.")
}
}
fmt.Println("-----------------------------")
log.Printf("[MCP] Debug: CLI execution finished.")
return // Exit after direct execution
}
// --- End Command Line Flag Handling ---
log.Printf("[MCP] Debug: Initializing MCP server...")
server := mcp_golang.NewServer(stdio.NewStdioServerTransport())
log.Printf("[MCP] Debug: Registering tool 'hello'...")
err := server.RegisterTool("hello", "Say hello to a person", func(arguments MyFunctionsArguments) (*mcp_golang.ToolResponse, error) {
log.Printf("[MCP] Debug: Tool 'hello' called with args: %+v", arguments)
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Submitter))), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register tool 'hello': %v", err)
}
log.Printf("[MCP] Debug: Registering tool 'get_artist_biography'...")
err = server.RegisterTool("get_artist_biography", "Get the biography of an artist", func(arguments ArtistBiography) (*mcp_golang.ToolResponse, error) {
log.Printf("[MCP] Debug: Tool 'get_artist_biography' called with args: %+v", arguments)
// Using background context in handlers as request context isn't passed through MCP library currently
bio, err := getArtistBiography(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
if err != nil {
log.Printf("[MCP] Error: getArtistBiography handler failed: %v", err)
return nil, fmt.Errorf("handler returned an error: %w", err) // Return structured error
}
log.Printf("[MCP] Debug: Tool 'get_artist_biography' succeeded.")
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(bio)), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register tool 'get_artist_biography': %v", err)
}
log.Printf("[MCP] Debug: Registering tool 'get_artist_url'...")
err = server.RegisterTool("get_artist_url", "Get the artist's specific Wikipedia URL via MBID, or a search URL using name as fallback", func(arguments ArtistURLArgs) (*mcp_golang.ToolResponse, error) {
log.Printf("[MCP] Debug: Tool 'get_artist_url' called with args: %+v", arguments)
urlResult, err := getArtistURL(fetcher, context.Background(), arguments.ID, arguments.Name, arguments.MBID)
if err != nil {
log.Printf("[MCP] Error: getArtistURL handler failed: %v", err)
return nil, fmt.Errorf("handler returned an error: %w", err)
}
log.Printf("[MCP] Debug: Tool 'get_artist_url' succeeded.")
return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(urlResult)), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register tool 'get_artist_url': %v", err)
}
log.Printf("[MCP] Debug: Registering prompt 'prompt_test'...")
err = server.RegisterPrompt("prompt_test", "This is a test prompt", func(arguments Content) (*mcp_golang.PromptResponse, error) {
log.Printf("[MCP] Debug: Prompt 'prompt_test' called with args: %+v", arguments)
return mcp_golang.NewPromptResponse("description", mcp_golang.NewPromptMessage(mcp_golang.NewTextContent(fmt.Sprintf("Hello, %server!", arguments.Title)), mcp_golang.RoleUser)), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register prompt 'prompt_test': %v", err)
}
log.Printf("[MCP] Debug: Registering resource 'test://resource'...")
err = server.RegisterResource("test://resource", "resource_test", "This is a test resource", "application/json", func() (*mcp_golang.ResourceResponse, error) {
log.Printf("[MCP] Debug: Resource 'test://resource' called")
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("test://resource", "This is a test resource", "application/json")), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register resource 'test://resource': %v", err)
}
log.Printf("[MCP] Debug: Registering resource 'file://app_logs'...")
err = server.RegisterResource("file://app_logs", "app_logs", "The app logs", "text/plain", func() (*mcp_golang.ResourceResponse, error) {
log.Printf("[MCP] Debug: Resource 'file://app_logs' called")
return mcp_golang.NewResourceResponse(mcp_golang.NewTextEmbeddedResource("file://app_logs", "This is a test resource", "text/plain")), nil
})
if err != nil {
log.Fatalf("[MCP] Fatal: Failed to register resource 'file://app_logs': %v", err)
}
log.Println("[MCP] MCP server initialized and starting to serve...")
err = server.Serve()
if err != nil {
log.Fatalf("[MCP] Fatal: Server exited with error: %v", err)
}
log.Println("[MCP] Server exited cleanly.")
<-done // Keep running until interrupted (though server.Serve() is blocking)
}
func getArtistBiography(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
log.Printf("[MCP] Debug: getArtistBiography called (id: %s, name: %s, mbid: %s)", id, name, mbid)
if mbid == "" {
fmt.Fprintf(os.Stderr, "MBID not provided, attempting DBpedia lookup by name: %s\n", name)
log.Printf("[MCP] Debug: MBID not provided, attempting DBpedia lookup by name: %s", name)
} else {
// 1. Attempt Wikidata MBID lookup first
log.Printf("[MCP] Debug: Attempting Wikidata URL lookup for MBID: %s", mbid)
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
if err == nil {
// 1a. Found Wikidata URL, now fetch from Wikipedia API
log.Printf("[MCP] Debug: Found Wikidata URL '%s', fetching bio from Wikipedia API...", wikiURL)
bio, errBio := GetBioFromWikipediaAPI(fetcher, ctx, wikiURL)
if errBio == nil {
log.Printf("[MCP] Debug: Successfully fetched bio from Wikipedia API for '%s'.", name)
return bio, nil // Success via Wikidata/Wikipedia!
} else {
// Failed to get bio even though URL was found
log.Printf("[MCP] Error: Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v", wikiURL, mbid, errBio)
fmt.Fprintf(os.Stderr, "Found Wikipedia URL (%s) via MBID %s, but failed to fetch bio: %v\n", wikiURL, mbid, errBio)
// Fall through to try DBpedia by name as a last resort?
// Let's fall through for now.
}
} else if !errors.Is(err, ErrNotFound) {
// Wikidata lookup failed for a reason other than not found (e.g., network)
log.Printf("[MCP] Error: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v", mbid, err)
fmt.Fprintf(os.Stderr, "Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
// Don't proceed to DBpedia name lookup if Wikidata had a technical failure
return "", fmt.Errorf("Wikidata lookup failed: %w", err)
} else {
// Wikidata lookup returned ErrNotFound for MBID
log.Printf("[MCP] Debug: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s", mbid, name)
fmt.Fprintf(os.Stderr, "MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
}
}
// 2. Attempt DBpedia lookup by name (if MBID was missing or failed with ErrNotFound)
if name == "" {
log.Printf("[MCP] Error: Cannot find artist bio: MBID lookup failed/missing, and no name provided.")
return "", fmt.Errorf("cannot find artist: MBID lookup failed or MBID not provided, and no name provided for DBpedia fallback")
}
log.Printf("[MCP] Debug: Attempting DBpedia bio lookup by name: %s", name)
dbpediaBio, errDb := GetArtistBioFromDBpedia(fetcher, ctx, name)
if errDb == nil {
log.Printf("[MCP] Debug: Successfully fetched bio from DBpedia for '%s'.", name)
return dbpediaBio, nil // Success via DBpedia!
}
// 3. If both Wikidata (MBID) and DBpedia (Name) failed
if errors.Is(errDb, ErrNotFound) {
log.Printf("[MCP] Error: Artist '%s' (MBID: %s) not found via Wikidata or DBpedia name lookup.", name, mbid)
return "", fmt.Errorf("artist '%s' (MBID: %s) not found via Wikidata MBID or DBpedia Name lookup", name, mbid)
}
// Return DBpedia's error if it wasn't ErrNotFound
log.Printf("[MCP] Error: DBpedia lookup failed for name '%s': %v", name, errDb)
return "", fmt.Errorf("DBpedia lookup failed for name '%s': %w", name, errDb)
}
// getArtistURL attempts to find the specific Wikipedia URL using MBID (via Wikidata),
// then by Name (via DBpedia), falling back to a search URL using name.
func getArtistURL(fetcher Fetcher, ctx context.Context, id, name, mbid string) (string, error) {
log.Printf("[MCP] Debug: getArtistURL called (id: %s, name: %s, mbid: %s)", id, name, mbid)
if mbid == "" {
fmt.Fprintf(os.Stderr, "getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s\n", name)
log.Printf("[MCP] Debug: getArtistURL: MBID not provided, attempting DBpedia lookup by name: %s", name)
} else {
// Try to get the specific URL from Wikidata using MBID
log.Printf("[MCP] Debug: getArtistURL: Attempting Wikidata URL lookup for MBID: %s", mbid)
wikiURL, err := GetArtistWikipediaURL(fetcher, ctx, mbid)
if err == nil && wikiURL != "" {
log.Printf("[MCP] Debug: getArtistURL: Found specific URL '%s' via Wikidata MBID.", wikiURL)
return wikiURL, nil // Found specific URL via MBID
}
// Log error if Wikidata lookup failed for reasons other than not found
if err != nil && !errors.Is(err, ErrNotFound) {
log.Printf("[MCP] Error: getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v", mbid, err)
fmt.Fprintf(os.Stderr, "getArtistURL: Wikidata URL lookup failed for MBID %s (non-NotFound error): %v\n", mbid, err)
// Fall through to try DBpedia if name is available
} else if errors.Is(err, ErrNotFound) {
log.Printf("[MCP] Debug: getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s", mbid, name)
fmt.Fprintf(os.Stderr, "getArtistURL: MBID %s not found on Wikidata, attempting DBpedia lookup by name: %s\n", mbid, name)
}
}
// Fallback 1: Try DBpedia lookup by name
if name != "" {
log.Printf("[MCP] Debug: getArtistURL: Attempting DBpedia URL lookup by name: %s", name)
dbpediaWikiURL, errDb := GetArtistWikipediaURLFromDBpedia(fetcher, ctx, name)
if errDb == nil && dbpediaWikiURL != "" {
log.Printf("[MCP] Debug: getArtistURL: Found specific URL '%s' via DBpedia Name lookup.", dbpediaWikiURL)
return dbpediaWikiURL, nil // Found specific URL via DBpedia Name lookup
}
// Log error if DBpedia lookup failed for reasons other than not found
if errDb != nil && !errors.Is(errDb, ErrNotFound) {
log.Printf("[MCP] Error: getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v", name, errDb)
fmt.Fprintf(os.Stderr, "getArtistURL: DBpedia URL lookup failed for name '%s' (non-NotFound error): %v\n", name, errDb)
// Fall through to search URL fallback
} else if errors.Is(errDb, ErrNotFound) {
log.Printf("[MCP] Debug: getArtistURL: Name '%s' not found on DBpedia, attempting search fallback", name)
fmt.Fprintf(os.Stderr, "getArtistURL: Name '%s' not found on DBpedia, attempting search fallback\n", name)
}
}
// Fallback 2: Generate a search URL if name is provided
if name != "" {
searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(name))
log.Printf("[MCP] Debug: getArtistURL: Falling back to search URL: %s", searchURL)
fmt.Fprintf(os.Stderr, "getArtistURL: Falling back to search URL: %s\n", searchURL)
return searchURL, nil
}
// Final error: MBID lookup failed (or no MBID given) AND no name provided for fallback
log.Printf("[MCP] Error: getArtistURL: Cannot generate Wikipedia URL: Lookups failed and no name provided.")
return "", fmt.Errorf("cannot generate Wikipedia URL: Wikidata/DBpedia lookups failed and no artist name provided for search fallback")
}

View File

@@ -0,0 +1,205 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"time"
)
const wikidataEndpoint = "https://query.wikidata.org/sparql"
// ErrNotFound indicates a specific item (like an artist or URL) was not found on Wikidata.
var ErrNotFound = errors.New("item not found on Wikidata")
// Wikidata SPARQL query result structures
type SparqlResult struct {
Results SparqlBindings `json:"results"`
}
type SparqlBindings struct {
Bindings []map[string]SparqlValue `json:"bindings"`
}
type SparqlValue struct {
Type string `json:"type"`
Value string `json:"value"`
Lang string `json:"xml:lang,omitempty"` // Handle language tags like "en"
}
// GetArtistBioFromWikidata queries Wikidata for an artist's description using their MBID.
// NOTE: This function is currently UNUSED as the main logic prefers Wikipedia/DBpedia.
func GetArtistBioFromWikidata(client *http.Client, mbid string) (string, error) {
log.Printf("[MCP] Debug: GetArtistBioFromWikidata called for MBID: %s", mbid)
if mbid == "" {
log.Printf("[MCP] Error: GetArtistBioFromWikidata requires an MBID.")
return "", fmt.Errorf("MBID is required to query Wikidata")
}
// SPARQL query to find the English description for an entity with a specific MusicBrainz ID
sparqlQuery := fmt.Sprintf(`
SELECT ?artistDescription WHERE {
?artist wdt:P434 "%s" . # P434 is the property for MusicBrainz artist ID
OPTIONAL {
?artist schema:description ?artistDescription .
FILTER(LANG(?artistDescription) = "en")
}
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
}
LIMIT 1`, mbid)
// Prepare the HTTP request
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "json")
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: Wikidata Bio Request URL: %s", reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
log.Printf("[MCP] Error: Failed to create Wikidata bio request: %v", err)
return "", fmt.Errorf("failed to create Wikidata request: %w", err)
}
req.Header.Set("Accept", "application/sparql-results+json")
req.Header.Set("User-Agent", "MCPGoServerExample/0.1 (https://example.com/contact)") // Good practice to identify your client
// Execute the request
log.Printf("[MCP] Debug: Executing Wikidata bio request...")
resp, err := client.Do(req)
if err != nil {
log.Printf("[MCP] Error: Failed to execute Wikidata bio request: %v", err)
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Attempt to read body for more error info, but don't fail if it doesn't work
bodyBytes, readErr := io.ReadAll(resp.Body)
errorMsg := "Could not read error body"
if readErr == nil {
errorMsg = string(bodyBytes)
}
log.Printf("[MCP] Error: Wikidata bio query failed with status %d: %s", resp.StatusCode, errorMsg)
return "", fmt.Errorf("Wikidata query failed with status %d: %s", resp.StatusCode, errorMsg)
}
log.Printf("[MCP] Debug: Wikidata bio query successful (status %d).", resp.StatusCode)
// Parse the response
var result SparqlResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Printf("[MCP] Error: Failed to decode Wikidata bio response: %v", err)
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
}
// Extract the description
if len(result.Results.Bindings) > 0 {
if descriptionVal, ok := result.Results.Bindings[0]["artistDescription"]; ok {
log.Printf("[MCP] Debug: Found description for MBID %s", mbid)
return descriptionVal.Value, nil
}
}
log.Printf("[MCP] Warn: No English description found on Wikidata for MBID %s", mbid)
return "", fmt.Errorf("no English description found on Wikidata for MBID %s", mbid)
}
// GetArtistWikipediaURL queries Wikidata for an artist's English Wikipedia page URL using MBID.
// It tries searching by MBID first, then falls back to searching by name.
func GetArtistWikipediaURL(fetcher Fetcher, ctx context.Context, mbid string) (string, error) {
log.Printf("[MCP] Debug: GetArtistWikipediaURL called for MBID: %s", mbid)
// 1. Try finding by MBID
if mbid == "" {
log.Printf("[MCP] Error: GetArtistWikipediaURL requires an MBID.")
return "", fmt.Errorf("MBID is required to find Wikipedia URL on Wikidata")
} else {
// SPARQL query to find the enwiki URL for an entity with a specific MusicBrainz ID
sparqlQuery := fmt.Sprintf(`
SELECT ?article WHERE {
?artist wdt:P434 "%s" . # P434 is MusicBrainz artist ID
?article schema:about ?artist ;
schema:isPartOf <https://en.wikipedia.org/> .
}
LIMIT 1`, mbid)
log.Printf("[MCP] Debug: Executing Wikidata URL query for MBID: %s", mbid)
foundURL, err := executeWikidataURLQuery(fetcher, ctx, sparqlQuery)
if err == nil && foundURL != "" {
log.Printf("[MCP] Debug: Found Wikipedia URL '%s' via MBID %s", foundURL, mbid)
return foundURL, nil // Found via MBID
}
// Use the specific ErrNotFound
if errors.Is(err, ErrNotFound) {
log.Printf("[MCP] Debug: MBID %s not found on Wikidata for URL lookup.", mbid)
return "", ErrNotFound // Explicitly return ErrNotFound
}
// Log other errors
if err != nil {
log.Printf("[MCP] Error: Wikidata URL lookup via MBID %s failed: %v", mbid, err)
fmt.Fprintf(os.Stderr, "Wikidata URL lookup via MBID %s failed: %v\n", mbid, err)
return "", fmt.Errorf("Wikidata URL lookup via MBID failed: %w", err)
}
}
// Should ideally not be reached if MBID is required and lookup failed or was not found
log.Printf("[MCP] Warn: Reached end of GetArtistWikipediaURL unexpectedly for MBID %s", mbid)
return "", ErrNotFound // Return ErrNotFound if somehow reached
}
// executeWikidataURLQuery is a helper to run SPARQL and extract the first bound URL for '?article'.
func executeWikidataURLQuery(fetcher Fetcher, ctx context.Context, sparqlQuery string) (string, error) {
log.Printf("[MCP] Debug: executeWikidataURLQuery called.")
queryValues := url.Values{}
queryValues.Set("query", sparqlQuery)
queryValues.Set("format", "json")
reqURL := fmt.Sprintf("%s?%s", wikidataEndpoint, queryValues.Encode())
log.Printf("[MCP] Debug: Wikidata Sparql Request URL: %s", reqURL)
// Directly use the fetcher
// Note: Headers (Accept, User-Agent) are now handled by the Fetcher implementation
// The WASM fetcher currently doesn't support setting them via the host func interface.
// Timeout is handled via context for native, and passed to host func for WASM.
// Let's use a default timeout here if not provided via context (e.g., 15s)
// TODO: Consider making timeout configurable or passed down
timeout := 15 * time.Second
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching from Wikidata with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for Wikidata SPARQL request: %v", err)
return "", fmt.Errorf("failed to execute Wikidata request: %w", err)
}
// Check status code. Fetcher interface implies body might be returned even on error.
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: Wikidata SPARQL query failed with status %d: %s", statusCode, string(bodyBytes))
return "", fmt.Errorf("Wikidata query failed with status %d: %s", statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: Wikidata SPARQL query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
var result SparqlResult
if err := json.Unmarshal(bodyBytes, &result); err != nil { // Use Unmarshal for byte slice
log.Printf("[MCP] Error: Failed to decode Wikidata SPARQL response: %v", err)
return "", fmt.Errorf("failed to decode Wikidata response: %w", err)
}
if len(result.Results.Bindings) > 0 {
if articleVal, ok := result.Results.Bindings[0]["article"]; ok {
log.Printf("[MCP] Debug: Found Wikidata article URL: %s", articleVal.Value)
return articleVal.Value, nil
}
}
log.Printf("[MCP] Debug: No Wikidata article URL found in SPARQL response.")
return "", ErrNotFound
}

View File

@@ -0,0 +1,121 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
)
const mediaWikiAPIEndpoint = "https://en.wikipedia.org/w/api.php"
// Structures for parsing MediaWiki API response (query extracts)
type MediaWikiQueryResult struct {
Query MediaWikiQuery `json:"query"`
}
type MediaWikiQuery struct {
Pages map[string]MediaWikiPage `json:"pages"`
}
type MediaWikiPage struct {
PageID int `json:"pageid"`
Ns int `json:"ns"`
Title string `json:"title"`
Extract string `json:"extract"`
}
// Default timeout for Wikipedia API requests
const defaultWikipediaTimeout = 15 * time.Second
// GetBioFromWikipediaAPI fetches the introductory text of a Wikipedia page.
func GetBioFromWikipediaAPI(fetcher Fetcher, ctx context.Context, wikipediaURL string) (string, error) {
log.Printf("[MCP] Debug: GetBioFromWikipediaAPI called for URL: %s", wikipediaURL)
pageTitle, err := extractPageTitleFromURL(wikipediaURL)
if err != nil {
log.Printf("[MCP] Error: Could not extract title from Wikipedia URL '%s': %v", wikipediaURL, err)
return "", fmt.Errorf("could not extract title from Wikipedia URL %s: %w", wikipediaURL, err)
}
log.Printf("[MCP] Debug: Extracted Wikipedia page title: %s", pageTitle)
// Prepare API request parameters
apiParams := url.Values{}
apiParams.Set("action", "query")
apiParams.Set("format", "json")
apiParams.Set("prop", "extracts") // Request page extracts
apiParams.Set("exintro", "true") // Get only the intro section
apiParams.Set("explaintext", "true") // Get plain text instead of HTML
apiParams.Set("titles", pageTitle) // Specify the page title
apiParams.Set("redirects", "1") // Follow redirects
reqURL := fmt.Sprintf("%s?%s", mediaWikiAPIEndpoint, apiParams.Encode())
log.Printf("[MCP] Debug: MediaWiki API Request URL: %s", reqURL)
timeout := defaultWikipediaTimeout
if deadline, ok := ctx.Deadline(); ok {
timeout = time.Until(deadline)
}
log.Printf("[MCP] Debug: Fetching from MediaWiki with timeout: %v", timeout)
statusCode, bodyBytes, err := fetcher.Fetch(ctx, "GET", reqURL, nil, timeout)
if err != nil {
log.Printf("[MCP] Error: Fetcher failed for MediaWiki request (title: '%s'): %v", pageTitle, err)
return "", fmt.Errorf("failed to execute MediaWiki request for title '%s': %w", pageTitle, err)
}
if statusCode != http.StatusOK {
log.Printf("[MCP] Error: MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
return "", fmt.Errorf("MediaWiki query for '%s' failed with status %d: %s", pageTitle, statusCode, string(bodyBytes))
}
log.Printf("[MCP] Debug: MediaWiki query successful (status %d), %d bytes received.", statusCode, len(bodyBytes))
// Parse the response
var result MediaWikiQueryResult
if err := json.Unmarshal(bodyBytes, &result); err != nil {
log.Printf("[MCP] Error: Failed to decode MediaWiki response for '%s': %v", pageTitle, err)
return "", fmt.Errorf("failed to decode MediaWiki response for '%s': %w", pageTitle, err)
}
// Extract the text - MediaWiki API returns pages keyed by page ID
for pageID, page := range result.Query.Pages {
log.Printf("[MCP] Debug: Processing MediaWiki page ID: %s, Title: %s", pageID, page.Title)
if page.Extract != "" {
// Often includes a newline at the end, trim it
log.Printf("[MCP] Debug: Found extract for '%s'. Length: %d", pageTitle, len(page.Extract))
return strings.TrimSpace(page.Extract), nil
}
}
log.Printf("[MCP] Warn: No extract found in MediaWiki response for title '%s'", pageTitle)
return "", fmt.Errorf("no extract found in MediaWiki response for title '%s' (page might not exist or be empty)", pageTitle)
}
// extractPageTitleFromURL attempts to get the page title from a standard Wikipedia URL.
// Example: https://en.wikipedia.org/wiki/The_Beatles -> The_Beatles
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/<title> format: %s", parsedURL.Path)
}
title := pathParts[1]
if title == "" {
return "", fmt.Errorf("extracted title is empty")
}
// URL Decode the title (e.g., %27 -> ')
decodedTitle, err := url.PathUnescape(title)
if err != nil {
return "", fmt.Errorf("failed to decode title '%s': %w", title, err)
}
return decodedTitle, nil
}

View File

@@ -0,0 +1,207 @@
package mcp
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"time"
mcp "github.com/metoro-io/mcp-golang"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// Constants used by the MCP agent
const (
McpAgentName = "mcp"
initializationTimeout = 5 * time.Second
// McpServerPath defines the location of the MCP server executable or WASM module.
// McpServerPath = "./core/agents/mcp/mcp-server/mcp-server"
McpServerPath = "./core/agents/mcp/mcp-server/mcp-server.wasm"
McpToolNameGetBio = "get_artist_biography"
McpToolNameGetURL = "get_artist_url"
)
// mcpClient interface matching the methods used from mcp.Client.
type mcpClient interface {
Initialize(ctx context.Context) (*mcp.InitializeResponse, error)
CallTool(ctx context.Context, toolName string, args any) (*mcp.ToolResponse, error)
}
// mcpImplementation defines the common interface for both native and WASM MCP agents.
// This allows the main MCPAgent to delegate calls without knowing the underlying type.
type mcpImplementation interface {
Close() error // For cleaning up resources associated with this specific implementation.
// callMCPTool is the core method implemented differently by native/wasm
callMCPTool(ctx context.Context, toolName string, args any) (string, error)
}
// MCPAgent is the public-facing agent registered with Navidrome.
// It acts as a wrapper around the actual implementation (native or WASM).
type MCPAgent struct {
// No mutex needed here if impl is set once at construction
// and the implementation handles its own internal state synchronization.
impl mcpImplementation
// Shared Wazero resources (runtime, cache) are managed externally
// and closed separately, likely during application shutdown.
}
// mcpConstructor creates the appropriate MCP implementation (native or WASM)
// and wraps it in the MCPAgent.
func mcpConstructor(ds model.DataStore) agents.Interface {
if _, err := os.Stat(McpServerPath); os.IsNotExist(err) {
log.Warn("MCP server executable/WASM not found, disabling agent", "path", McpServerPath, "error", err)
return nil
}
var agentImpl mcpImplementation
var err error
if strings.HasSuffix(McpServerPath, ".wasm") {
log.Info("Configuring MCP agent for WASM execution", "path", McpServerPath)
ctx := context.Background()
// Setup Shared Wazero Resources
var cache wazero.CompilationCache
cacheDir := filepath.Join(conf.Server.DataFolder, "cache", "wazero")
if errMkdir := os.MkdirAll(cacheDir, 0755); errMkdir != nil {
log.Error(ctx, "Failed to create Wazero cache directory, WASM caching disabled", "path", cacheDir, "error", errMkdir)
} else {
cache, err = wazero.NewCompilationCacheWithDir(cacheDir)
if err != nil {
log.Error(ctx, "Failed to create Wazero compilation cache, WASM caching disabled", "path", cacheDir, "error", err)
cache = nil
}
}
runtimeConfig := wazero.NewRuntimeConfig()
if cache != nil {
runtimeConfig = runtimeConfig.WithCompilationCache(cache)
}
runtime := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
if err = registerHostFunctions(ctx, runtime); err != nil {
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil // Fatal error: Host functions required
}
if _, err = wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
log.Error(ctx, "Failed to instantiate WASI on shared Wazero runtime, MCP WASM agent disabled", "error", err)
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil // Fatal error: WASI required
}
// Compile the module
log.Debug(ctx, "Pre-compiling WASM module...", "path", McpServerPath)
wasmBytes, err := os.ReadFile(McpServerPath)
if err != nil {
log.Error(ctx, "Failed to read WASM module file, disabling agent", "path", McpServerPath, "error", err)
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil
}
compiledModule, err := runtime.CompileModule(ctx, wasmBytes)
if err != nil {
log.Error(ctx, "Failed to pre-compile WASM module, disabling agent", "path", McpServerPath, "error", err)
_ = runtime.Close(ctx)
if cache != nil {
_ = cache.Close(ctx)
}
return nil
}
agentImpl = newMCPWasm(runtime, cache, compiledModule)
log.Info(ctx, "Shared Wazero runtime, WASI, cache, host functions initialized, and module pre-compiled for MCP agent")
} else {
log.Info("Configuring MCP agent for native execution", "path", McpServerPath)
agentImpl = newMCPNative()
}
log.Info("MCP Agent implementation created successfully")
return &MCPAgent{impl: agentImpl}
}
// NewAgentForTesting is a constructor specifically for tests.
// It creates the appropriate implementation based on McpServerPath
// and injects a mock mcpClient into its ClientOverride field.
func NewAgentForTesting(mockClient mcpClient) agents.Interface {
// We need to replicate the logic from mcpConstructor to determine
// the implementation type, but without actually starting processes.
var agentImpl mcpImplementation
if strings.HasSuffix(McpServerPath, ".wasm") {
// For WASM testing, we might not need the full runtime setup,
// just the struct. Pass nil for shared resources for now.
// We rely on the mockClient being used before any real WASM interaction.
wasmImpl := newMCPWasm(nil, nil, nil)
wasmImpl.ClientOverride = mockClient
agentImpl = wasmImpl
} else {
nativeImpl := newMCPNative()
nativeImpl.ClientOverride = mockClient
agentImpl = nativeImpl
}
return &MCPAgent{impl: agentImpl}
}
func (a *MCPAgent) AgentName() string {
return McpAgentName
}
func (a *MCPAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
if a.impl == nil {
return "", errors.New("MCP agent implementation is nil")
}
// Construct args and call the implementation's specific tool caller
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
return a.impl.callMCPTool(ctx, McpToolNameGetBio, args)
}
func (a *MCPAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if a.impl == nil {
return "", errors.New("MCP agent implementation is nil")
}
// Construct args and call the implementation's specific tool caller
args := ArtistArgs{ID: id, Name: name, Mbid: mbid}
return a.impl.callMCPTool(ctx, McpToolNameGetURL, args)
}
// Note: A Close method on MCPAgent itself isn't part of agents.Interface.
// Cleanup of the specific implementation happens via impl.Close().
// Cleanup of shared Wazero resources needs separate handling (e.g., on app shutdown).
// ArtistArgs defines the structure for MCP tool arguments requiring artist info.
type ArtistArgs struct {
ID string `json:"id"`
Name string `json:"name"`
Mbid string `json:"mbid,omitempty"`
}
var _ agents.ArtistBiographyRetriever = (*MCPAgent)(nil)
var _ agents.ArtistURLRetriever = (*MCPAgent)(nil)
func init() {
agents.Register(McpAgentName, mcpConstructor)
}

View File

@@ -0,0 +1,237 @@
package mcp_test
import (
"context"
"errors"
"fmt"
"io"
mcp_client "github.com/metoro-io/mcp-golang" // Renamed alias for clarity
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/agents/mcp"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Define the mcpClient interface locally for mocking, matching the one
// used internally by MCPNative/MCPWasm.
type mcpClient interface {
Initialize(ctx context.Context) (*mcp_client.InitializeResponse, error)
CallTool(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error)
}
// mockMCPClient is a mock implementation of mcpClient for testing.
type mockMCPClient struct {
InitializeFunc func(ctx context.Context) (*mcp_client.InitializeResponse, error)
CallToolFunc func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error)
callToolArgs []any // Store args for verification
callToolName string // Store tool name for verification
}
func (m *mockMCPClient) Initialize(ctx context.Context) (*mcp_client.InitializeResponse, error) {
if m.InitializeFunc != nil {
return m.InitializeFunc(ctx)
}
return &mcp_client.InitializeResponse{}, nil // Default success
}
func (m *mockMCPClient) CallTool(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
m.callToolName = toolName
m.callToolArgs = append(m.callToolArgs, args)
if m.CallToolFunc != nil {
return m.CallToolFunc(ctx, toolName, args)
}
return &mcp_client.ToolResponse{}, nil
}
// Ensure mock implements the local interface (compile-time check)
var _ mcpClient = (*mockMCPClient)(nil)
var _ = Describe("MCPAgent", func() {
var (
ctx context.Context
// We test the public MCPAgent wrapper, which uses the implementations internally.
// The actual agent instance might be native or wasm depending on McpServerPath
agent agents.Interface // Use the public agents.Interface
mockClient *mockMCPClient
)
BeforeEach(func() {
ctx = context.Background()
mockClient = &mockMCPClient{
callToolArgs: make([]any, 0), // Reset args on each test
}
// Instantiate the real agent using a testing constructor
// This constructor needs to be added to the mcp package.
agent = mcp.NewAgentForTesting(mockClient)
Expect(agent).NotTo(BeNil(), "Agent should be created")
})
// Helper to get the concrete agent type for calling specific methods
getConcreteAgent := func() *mcp.MCPAgent {
concreteAgent, ok := agent.(*mcp.MCPAgent)
Expect(ok).To(BeTrue(), "Agent should be of type *mcp.MCPAgent")
return concreteAgent
}
Describe("GetArtistBiography", func() {
It("should call the correct tool and return the biography", func() {
expectedBio := "This is the artist bio."
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
Expect(toolName).To(Equal(mcp.McpToolNameGetBio))
Expect(args).To(BeAssignableToTypeOf(mcp.ArtistArgs{}))
typedArgs := args.(mcp.ArtistArgs)
Expect(typedArgs.ID).To(Equal("id1"))
Expect(typedArgs.Name).To(Equal("Artist Name"))
Expect(typedArgs.Mbid).To(Equal("mbid1"))
return mcp_client.NewToolResponse(mcp_client.NewTextContent(expectedBio)), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).NotTo(HaveOccurred())
Expect(bio).To(Equal(expectedBio))
})
It("should return error if CallTool fails", func() {
expectedErr := errors.New("mcp tool error")
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, expectedErr
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(HaveOccurred())
// The error originates from the implementation now, check for specific part
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent not ready"), // Error from native
ContainSubstring("WASM MCP agent not ready"), // Error from WASM
ContainSubstring("failed to call native MCP tool"),
ContainSubstring("failed to call WASM MCP tool"),
))
Expect(errors.Is(err, expectedErr)).To(BeTrue())
Expect(bio).To(BeEmpty())
})
It("should return ErrNotFound if CallTool response is empty", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
// Return a response created with no content parts
return mcp_client.NewToolResponse(), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(bio).To(BeEmpty())
})
It("should return ErrNotFound if CallTool response has nil TextContent (simulated by empty string)", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
// Simulate nil/empty text content by creating response with empty string text
return mcp_client.NewToolResponse(mcp_client.NewTextContent("")), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(bio).To(BeEmpty())
})
It("should return comm error if CallTool returns pipe error", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, io.ErrClosedPipe
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent process communication error"),
ContainSubstring("WASM MCP agent module communication error"),
))
Expect(errors.Is(err, io.ErrClosedPipe)).To(BeTrue())
Expect(bio).To(BeEmpty())
})
It("should return ErrNotFound if MCP tool returns an error string", func() {
mcpErrorString := "handler returned an error: something went wrong on the server"
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return mcp_client.NewToolResponse(mcp_client.NewTextContent(mcpErrorString)), nil
}
bio, err := getConcreteAgent().GetArtistBiography(ctx, "id1", "Artist Name", "mbid1")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(bio).To(BeEmpty())
})
})
Describe("GetArtistURL", func() {
It("should call the correct tool and return the URL", func() {
expectedURL := "http://example.com/artist"
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
Expect(toolName).To(Equal(mcp.McpToolNameGetURL))
Expect(args).To(BeAssignableToTypeOf(mcp.ArtistArgs{}))
typedArgs := args.(mcp.ArtistArgs)
Expect(typedArgs.ID).To(Equal("id2"))
Expect(typedArgs.Name).To(Equal("Another Artist"))
Expect(typedArgs.Mbid).To(Equal("mbid2"))
return mcp_client.NewToolResponse(mcp_client.NewTextContent(expectedURL)), nil
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).NotTo(HaveOccurred())
Expect(url).To(Equal(expectedURL))
})
It("should return error if CallTool fails", func() {
expectedErr := errors.New("mcp tool error url")
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, expectedErr
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent not ready"), // Error from native
ContainSubstring("WASM MCP agent not ready"), // Error from WASM
ContainSubstring("failed to call native MCP tool"),
ContainSubstring("failed to call WASM MCP tool"),
))
Expect(errors.Is(err, expectedErr)).To(BeTrue())
Expect(url).To(BeEmpty())
})
It("should return ErrNotFound if CallTool response is empty", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
// Return a response created with no content parts
return mcp_client.NewToolResponse(), nil
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(url).To(BeEmpty())
})
It("should return comm error if CallTool returns pipe error", func() {
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return nil, fmt.Errorf("write: %w", io.ErrClosedPipe)
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(SatisfyAny(
ContainSubstring("native MCP agent process communication error"),
ContainSubstring("WASM MCP agent module communication error"),
))
Expect(errors.Is(err, io.ErrClosedPipe)).To(BeTrue())
Expect(url).To(BeEmpty())
})
It("should return ErrNotFound if MCP tool returns an error string", func() {
mcpErrorString := "handler returned an error: could not find url"
mockClient.CallToolFunc = func(ctx context.Context, toolName string, args any) (*mcp_client.ToolResponse, error) {
return mcp_client.NewToolResponse(mcp_client.NewTextContent(mcpErrorString)), nil
}
url, err := getConcreteAgent().GetArtistURL(ctx, "id2", "Another Artist", "mbid2")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(url).To(BeEmpty())
})
})
})

View File

@@ -0,0 +1,189 @@
package mcp
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/navidrome/navidrome/log"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
)
// httpClient is a shared HTTP client for host function reuse.
var httpClient = &http.Client{
// Consider adding a default timeout
Timeout: 30 * time.Second,
}
// registerHostFunctions defines and registers the host functions (e.g., http_fetch)
// into the provided Wazero runtime.
func registerHostFunctions(ctx context.Context, runtime wazero.Runtime) error {
// Define and Instantiate Host Module "env"
_, err := runtime.NewHostModuleBuilder("env"). // "env" is the conventional module name
NewFunctionBuilder().
WithFunc(httpFetch). // Register our Go function
Export("http_fetch"). // Export it with the name WASM will use
Instantiate(ctx)
if err != nil {
log.Error(ctx, "Failed to instantiate 'env' host module with httpFetch", "error", err)
return fmt.Errorf("instantiate host module 'env': %w", err)
}
log.Info(ctx, "Instantiated 'env' host module with http_fetch function")
return nil
}
// httpFetch is the host function exposed to WASM.
// ... (full implementation as provided previously) ...
// Returns:
// - 0 on success (request completed, results written).
// - 1 on host-side failure (e.g., memory access error, invalid input).
func httpFetch(
ctx context.Context, mod api.Module, // Standard Wazero host function params
// Request details
urlPtr, urlLen uint32,
methodPtr, methodLen uint32,
bodyPtr, bodyLen uint32,
timeoutMillis uint32,
// Result pointers
resultStatusPtr uint32,
resultBodyPtr uint32, resultBodyCapacity uint32, resultBodyLenPtr uint32,
resultErrorPtr uint32, resultErrorCapacity uint32, resultErrorLenPtr uint32,
) uint32 { // Using uint32 for status code convention (0=success, 1=failure)
mem := mod.Memory()
// --- Read Inputs ---
urlBytes, ok := mem.Read(urlPtr, urlLen)
if !ok {
log.Error(ctx, "httpFetch host error: failed to read URL from WASM memory")
// Cannot write error back as we don't have the pointers validated yet
return 1
}
url := string(urlBytes)
methodBytes, ok := mem.Read(methodPtr, methodLen)
if !ok {
log.Error(ctx, "httpFetch host error: failed to read method from WASM memory", "url", url)
return 1 // Bail out
}
method := string(methodBytes)
if method == "" {
method = "GET" // Default to GET
}
var reqBody io.Reader
if bodyLen > 0 {
bodyBytes, ok := mem.Read(bodyPtr, bodyLen)
if !ok {
log.Error(ctx, "httpFetch host error: failed to read body from WASM memory", "url", url, "method", method)
return 1 // Bail out
}
reqBody = bytes.NewReader(bodyBytes)
}
timeout := time.Duration(timeoutMillis) * time.Millisecond
if timeout <= 0 {
timeout = 30 * time.Second // Default timeout matching httpClient
}
// --- Prepare and Execute Request ---
log.Debug(ctx, "httpFetch executing request", "method", method, "url", url, "timeout", timeout)
// Use a specific context for the request, derived from the host function's context
// but with the specific timeout for this call.
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, method, url, reqBody)
if err != nil {
errMsg := fmt.Sprintf("failed to create request: %v", err)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultStatusPtr, 0) // Write 0 status on creation error
mem.WriteUint32Le(resultBodyLenPtr, 0) // No body
return 0 // Indicate results (including error) were written
}
// TODO: Consider adding a User-Agent?
// req.Header.Set("User-Agent", "Navidrome/MCP-Agent-Host")
resp, err := httpClient.Do(req)
if err != nil {
// Handle client-side errors (network, DNS, timeout)
errMsg := fmt.Sprintf("failed to execute request: %v", err)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultStatusPtr, 0) // Write 0 status on transport error
mem.WriteUint32Le(resultBodyLenPtr, 0)
return 0 // Indicate results written
}
defer resp.Body.Close()
// --- Process Response ---
statusCode := uint32(resp.StatusCode)
responseBodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
errMsg := fmt.Sprintf("failed to read response body: %v", readErr)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "status", statusCode, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultStatusPtr, statusCode) // Write actual status code
mem.WriteUint32Le(resultBodyLenPtr, 0)
return 0 // Indicate results written
}
// --- Write Results Back to WASM Memory ---
log.Debug(ctx, "httpFetch writing results", "url", url, "method", method, "status", statusCode, "bodyLen", len(responseBodyBytes))
// Write status code
if !mem.WriteUint32Le(resultStatusPtr, statusCode) {
log.Error(ctx, "httpFetch host error: failed to write status code to WASM memory")
return 1 // Host error
}
// Write response body (checking capacity)
if !writeBytesResult(mem, resultBodyPtr, resultBodyCapacity, resultBodyLenPtr, responseBodyBytes) {
// If body write fails (likely due to capacity), write an error message instead.
errMsg := fmt.Sprintf("response body size (%d) exceeds buffer capacity (%d)", len(responseBodyBytes), resultBodyCapacity)
log.Error(ctx, "httpFetch host error", "url", url, "method", method, "status", statusCode, "error", errMsg)
writeStringResult(mem, resultErrorPtr, resultErrorCapacity, resultErrorLenPtr, errMsg)
mem.WriteUint32Le(resultBodyLenPtr, 0) // Ensure body length is 0 if we wrote an error
} else {
// Write empty error string if body write was successful
mem.WriteUint32Le(resultErrorLenPtr, 0)
}
return 0 // Success
}
// Helper to write string results, respecting capacity. Returns true on success.
func writeStringResult(mem api.Memory, ptr, capacity, lenPtr uint32, result string) bool {
bytes := []byte(result)
return writeBytesResult(mem, ptr, capacity, lenPtr, bytes)
}
// Helper to write byte results, respecting capacity. Returns true on success.
func writeBytesResult(mem api.Memory, ptr, capacity, lenPtr uint32, result []byte) bool {
resultLen := uint32(len(result))
writeLen := resultLen
if writeLen > capacity {
log.Warn(context.Background(), "WASM host write truncated", "requested", resultLen, "capacity", capacity)
writeLen = capacity // Truncate if too large for buffer
}
if writeLen > 0 {
if !mem.Write(ptr, result[:writeLen]) {
log.Error(context.Background(), "WASM host memory write failed", "ptr", ptr, "len", writeLen)
return false // Memory write failed
}
}
// Write the *original* length of the data (even if truncated) so the WASM side knows.
if !mem.WriteUint32Le(lenPtr, resultLen) {
log.Error(context.Background(), "WASM host memory length write failed", "lenPtr", lenPtr, "len", resultLen)
return false // Memory write failed
}
return true
}

View File

@@ -0,0 +1,252 @@
package mcp
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
// MCPNative implements the mcpImplementation interface for running the MCP server as a native process.
type MCPNative struct {
mu sync.Mutex
cmd *exec.Cmd // Stores the running command
stdin io.WriteCloser
client mcpClient
// ClientOverride allows injecting a mock client for testing this specific implementation.
ClientOverride mcpClient // TODO: Consider if this is the best way to test
}
// newMCPNative creates a new instance of the native MCP agent implementation.
func newMCPNative() *MCPNative {
return &MCPNative{}
}
// --- mcpImplementation interface methods ---
func (n *MCPNative) Close() error {
n.mu.Lock()
defer n.mu.Unlock()
n.cleanupResources_locked()
return nil // Currently, cleanup doesn't return errors
}
// --- Internal Helper Methods ---
// ensureClientInitialized starts the MCP server process and initializes the client if needed.
// MUST be called with the mutex HELD.
func (n *MCPNative) ensureClientInitialized_locked(ctx context.Context) error {
// Use override if provided (for testing)
if n.ClientOverride != nil {
if n.client == nil {
n.client = n.ClientOverride
log.Debug(ctx, "Using provided MCP client override for native testing")
}
return nil
}
// Check if already initialized
if n.client != nil {
return nil
}
log.Info(ctx, "Initializing Native MCP client and starting/restarting server process...", "serverPath", McpServerPath)
// Clean up any old resources *before* starting new ones
n.cleanupResources_locked()
hostStdinWriter, hostStdoutReader, nativeCmd, startErr := n.startProcess_locked(ctx)
if startErr != nil {
log.Error(ctx, "Failed to start Native MCP server process", "error", startErr)
// Ensure pipes are closed if start failed (startProcess might handle this, but be sure)
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
return fmt.Errorf("failed to start native MCP server: %w", startErr)
}
// --- Initialize MCP client ---
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
clientImpl := mcp.NewClient(transport)
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
defer cancel()
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
err := fmt.Errorf("failed to initialize native MCP client: %w", initErr)
log.Error(ctx, "Native MCP client initialization failed", "error", err)
// Cleanup the newly started process and close pipes
n.cmd = nativeCmd // Temporarily set cmd so cleanup can kill it
n.cleanupResources_locked()
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
return err
}
// --- Initialization successful, update agent state ---
n.cmd = nativeCmd
n.stdin = hostStdinWriter // This is the pipe the agent writes to
n.client = clientImpl
log.Info(ctx, "Native MCP client initialized successfully", "pid", n.cmd.Process.Pid)
return nil // Success
}
// callMCPTool handles ensuring initialization and calling the MCP tool.
func (n *MCPNative) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
// Ensure the client is initialized and the server is running (attempts restart if needed)
n.mu.Lock()
err := n.ensureClientInitialized_locked(ctx)
if err != nil {
n.mu.Unlock()
log.Error(ctx, "Native MCP agent initialization/restart failed", "tool", toolName, "error", err)
return "", fmt.Errorf("native MCP agent not ready: %w", err)
}
// Keep a reference to the client while locked
currentClient := n.client
// Unlock mutex *before* making the potentially blocking MCP call
n.mu.Unlock()
// Call the tool using the client reference
log.Debug(ctx, "Calling Native MCP tool", "tool", toolName, "args", args)
response, callErr := currentClient.CallTool(ctx, toolName, args)
if callErr != nil {
// Handle potential pipe closures or other communication errors
log.Error(ctx, "Failed to call Native MCP tool", "tool", toolName, "error", callErr)
// Check if the error indicates a broken pipe, suggesting the server died
// The monitoring goroutine will handle cleanup, just return error here.
if errors.Is(callErr, io.ErrClosedPipe) || strings.Contains(callErr.Error(), "broken pipe") || strings.Contains(callErr.Error(), "EOF") {
log.Warn(ctx, "Native MCP tool call failed, possibly due to server process exit.", "tool", toolName)
// No need to explicitly call cleanup, monitoring goroutine handles it.
return "", fmt.Errorf("native MCP agent process communication error: %w", callErr)
}
return "", fmt.Errorf("failed to call native MCP tool '%s': %w", toolName, callErr)
}
// Process the response (same logic as before)
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
log.Warn(ctx, "Native MCP tool returned empty/invalid response", "tool", toolName)
// Treat as not found for agent interface consistency
return "", agents.ErrNotFound // Import agents package if needed, or define locally
}
resultText := response.Content[0].TextContent.Text
if strings.HasPrefix(resultText, "handler returned an error:") {
log.Warn(ctx, "Native MCP tool returned an error message", "tool", toolName, "mcpError", resultText)
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
}
log.Debug(ctx, "Received response from Native MCP agent", "tool", toolName, "length", len(resultText))
return resultText, nil
}
// cleanupResources closes existing resources (stdin, server process).
// MUST be called with the mutex HELD.
func (n *MCPNative) cleanupResources_locked() {
log.Debug(context.Background(), "Cleaning up Native MCP instance resources...")
if n.stdin != nil {
_ = n.stdin.Close()
n.stdin = nil
}
if n.cmd != nil && n.cmd.Process != nil {
pid := n.cmd.Process.Pid
log.Debug(context.Background(), "Killing native MCP process", "pid", pid)
// Kill the process. Ignore error if it's already done.
if err := n.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
log.Error(context.Background(), "Failed to kill native process", "pid", pid, "error", err)
}
// Wait for the process to release resources. Ignore error.
_ = n.cmd.Wait()
n.cmd = nil
}
// Mark client as invalid
n.client = nil
}
// startProcess starts the MCP server as a native executable and sets up monitoring.
// MUST be called with the mutex HELD.
func (n *MCPNative) startProcess_locked(ctx context.Context) (stdin io.WriteCloser, stdout io.ReadCloser, cmd *exec.Cmd, err error) {
log.Debug(ctx, "Starting native MCP server process", "path", McpServerPath)
// Use Background context for the command itself, as it should outlive the request context (ctx)
cmd = exec.CommandContext(context.Background(), McpServerPath)
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("native stdin pipe: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
_ = stdinPipe.Close()
return nil, nil, nil, fmt.Errorf("native stdout pipe: %w", err)
}
// Get stderr pipe to stream logs
stderrPipe, err := cmd.StderrPipe()
if err != nil {
_ = stdinPipe.Close()
_ = stdoutPipe.Close()
return nil, nil, nil, fmt.Errorf("native stderr pipe: %w", err)
}
if err = cmd.Start(); err != nil {
_ = stdinPipe.Close()
_ = stdoutPipe.Close()
// stderrPipe gets closed implicitly if cmd.Start() fails
return nil, nil, nil, fmt.Errorf("native start: %w", err)
}
currentPid := cmd.Process.Pid
currentCmd := cmd // Capture the current cmd pointer for the goroutine
log.Info(ctx, "Native MCP server process started", "pid", currentPid)
// Start monitoring goroutine for process exit
go func() {
// Start separate goroutine to stream stderr
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
log.Info("[MCP-SERVER] " + scanner.Text())
}
if err := scanner.Err(); err != nil {
log.Error("Error reading MCP server stderr", "pid", currentPid, "error", err)
}
log.Debug("MCP server stderr pipe closed", "pid", currentPid)
}()
waitErr := currentCmd.Wait() // Wait for the specific process this goroutine monitors
n.mu.Lock()
// Stderr is now streamed, so we don't capture it here anymore.
log.Warn("Native MCP server process exited", "pid", currentPid, "error", waitErr)
// Critical: Check if the agent's current command is STILL the one we were monitoring.
// If it's different, it means cleanup/restart already happened, so we shouldn't cleanup again.
if n.cmd == currentCmd {
n.cleanupResources_locked() // Use the locked version as we hold the lock
log.Info("MCP Native agent state cleaned up after process exit", "pid", currentPid)
} else {
log.Debug("Native MCP process exited, but state already updated/cmd mismatch", "exitedPid", currentPid)
}
n.mu.Unlock()
}()
// Return the pipes connected to the process and the Cmd object
return stdinPipe, stdoutPipe, cmd, nil
}

View File

@@ -0,0 +1,305 @@
package mcp
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/navidrome/navidrome/core/agents" // Needed for ErrNotFound
"github.com/navidrome/navidrome/log"
"github.com/tetratelabs/wazero" // Needed for types
"github.com/tetratelabs/wazero/api" // Needed for types
)
// MCPWasm implements the mcpImplementation interface for running the MCP server as a WASM module.
type MCPWasm struct {
mu sync.Mutex
wasmModule api.Module // Stores the instantiated module
wasmCompiled api.Closer // Stores the compiled module reference for this instance
stdin io.WriteCloser
client mcpClient
// Shared resources (passed in, not owned by this struct)
wasmRuntime api.Closer // Shared Wazero Runtime
wasmCache wazero.CompilationCache // Shared Compilation Cache (can be nil)
preCompiledModule wazero.CompiledModule // Pre-compiled module from constructor
// ClientOverride allows injecting a mock client for testing this specific implementation.
ClientOverride mcpClient // TODO: Consider if this is the best way to test
}
// newMCPWasm creates a new instance of the WASM MCP agent implementation.
// It stores the shared runtime, cache, and the pre-compiled module.
func newMCPWasm(runtime api.Closer, cache wazero.CompilationCache, compiledModule wazero.CompiledModule) *MCPWasm {
return &MCPWasm{
wasmRuntime: runtime,
wasmCache: cache,
preCompiledModule: compiledModule,
}
}
// --- mcpImplementation interface methods ---
// Close cleans up instance-specific WASM resources.
// It does NOT close the shared runtime or cache.
func (w *MCPWasm) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
w.cleanupResources_locked()
return nil
}
// --- Internal Helper Methods ---
// ensureClientInitialized starts the MCP WASM module and initializes the client if needed.
// MUST be called with the mutex HELD.
func (w *MCPWasm) ensureClientInitialized_locked(ctx context.Context) error {
// Use override if provided (for testing)
if w.ClientOverride != nil {
if w.client == nil {
w.client = w.ClientOverride
log.Debug(ctx, "Using provided MCP client override for WASM testing")
}
return nil
}
// Check if already initialized
if w.client != nil {
return nil
}
log.Info(ctx, "Initializing WASM MCP client and starting/restarting server module...", "serverPath", McpServerPath)
w.cleanupResources_locked()
// Check if shared runtime exists
if w.wasmRuntime == nil {
return errors.New("shared Wazero runtime not initialized for MCPWasm")
}
hostStdinWriter, hostStdoutReader, mod, err := w.startModule_locked(ctx)
if err != nil {
log.Error(ctx, "Failed to start WASM MCP server module", "error", err)
// Ensure pipes are closed if start failed
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
// startModule_locked handles cleanup of mod/compiled on error
return fmt.Errorf("failed to start WASM MCP server: %w", err)
}
transport := stdio.NewStdioServerTransportWithIO(hostStdoutReader, hostStdinWriter)
clientImpl := mcp.NewClient(transport)
initCtx, cancel := context.WithTimeout(context.Background(), initializationTimeout)
defer cancel()
if _, initErr := clientImpl.Initialize(initCtx); initErr != nil {
err := fmt.Errorf("failed to initialize WASM MCP client: %w", initErr)
log.Error(ctx, "WASM MCP client initialization failed", "error", err)
// Cleanup the newly started module and close pipes
w.wasmModule = mod // Temporarily set so cleanup can close it
w.wasmCompiled = nil // We don't store the compiled instance ref anymore, just the module instance
w.cleanupResources_locked()
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
return err
}
w.wasmModule = mod
w.wasmCompiled = nil // We don't store the compiled instance ref anymore, just the module instance
w.stdin = hostStdinWriter
w.client = clientImpl
log.Info(ctx, "WASM MCP client initialized successfully")
return nil
}
// callMCPTool handles ensuring initialization and calling the MCP tool.
func (w *MCPWasm) callMCPTool(ctx context.Context, toolName string, args any) (string, error) {
w.mu.Lock()
err := w.ensureClientInitialized_locked(ctx)
if err != nil {
w.mu.Unlock()
log.Error(ctx, "WASM MCP agent initialization/restart failed", "tool", toolName, "error", err)
return "", fmt.Errorf("WASM MCP agent not ready: %w", err)
}
// Keep a reference to the client while locked
currentClient := w.client
// Unlock mutex *before* making the potentially blocking MCP call
w.mu.Unlock()
// Call the tool using the client reference
log.Debug(ctx, "Calling WASM MCP tool", "tool", toolName, "args", args)
response, callErr := currentClient.CallTool(ctx, toolName, args)
if callErr != nil {
// Handle potential pipe closures or other communication errors
log.Error(ctx, "Failed to call WASM MCP tool", "tool", toolName, "error", callErr)
// Check if the error indicates a broken pipe, suggesting the server died
// The monitoring goroutine will handle cleanup, just return error here.
if errors.Is(callErr, io.ErrClosedPipe) || strings.Contains(callErr.Error(), "broken pipe") || strings.Contains(callErr.Error(), "EOF") {
log.Warn(ctx, "WASM MCP tool call failed, possibly due to server module exit.", "tool", toolName)
// No need to explicitly call cleanup, monitoring goroutine handles it.
return "", fmt.Errorf("WASM MCP agent module communication error: %w", callErr)
}
return "", fmt.Errorf("failed to call WASM MCP tool '%s': %w", toolName, callErr)
}
// Process the response (same logic as native)
if response == nil || len(response.Content) == 0 || response.Content[0].TextContent == nil || response.Content[0].TextContent.Text == "" {
log.Warn(ctx, "WASM MCP tool returned empty/invalid response", "tool", toolName)
return "", agents.ErrNotFound
}
resultText := response.Content[0].TextContent.Text
if strings.HasPrefix(resultText, "handler returned an error:") {
log.Warn(ctx, "WASM MCP tool returned an error message", "tool", toolName, "mcpError", resultText)
return "", agents.ErrNotFound // Treat MCP tool errors as "not found"
}
log.Debug(ctx, "Received response from WASM MCP agent", "tool", toolName, "length", len(resultText))
return resultText, nil
}
// cleanupResources closes instance-specific WASM resources (stdin, module, compiled ref).
// It specifically avoids closing the shared runtime or cache.
// MUST be called with the mutex HELD.
func (w *MCPWasm) cleanupResources_locked() {
log.Debug(context.Background(), "Cleaning up WASM MCP instance resources...")
if w.stdin != nil {
_ = w.stdin.Close()
w.stdin = nil
}
// Close the module instance
if w.wasmModule != nil {
log.Debug(context.Background(), "Closing WASM module instance")
ctxClose, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := w.wasmModule.Close(ctxClose); err != nil && !errors.Is(err, context.DeadlineExceeded) {
log.Error(context.Background(), "Failed to close WASM module instance", "error", err)
}
cancel()
w.wasmModule = nil
}
// DO NOT close w.wasmCompiled (instance ref)
// DO NOT close w.preCompiledModule (shared pre-compiled code)
// DO NOT CLOSE w.wasmRuntime or w.wasmCache here!
w.client = nil
}
// startModule loads and starts the MCP server as a WASM module.
// It now uses the pre-compiled module.
// MUST be called with the mutex HELD.
func (w *MCPWasm) startModule_locked(ctx context.Context) (hostStdinWriter io.WriteCloser, hostStdoutReader io.ReadCloser, mod api.Module, err error) {
// Check for pre-compiled module
if w.preCompiledModule == nil {
return nil, nil, nil, errors.New("pre-compiled WASM module is nil")
}
// Create pipes for stdio redirection
wasmStdinReader, hostStdinWriter, err := os.Pipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("wasm stdin pipe: %w", err)
}
// Defer close pipes on error exit
shouldClosePipesOnError := true
defer func() {
if shouldClosePipesOnError {
if wasmStdinReader != nil {
_ = wasmStdinReader.Close()
}
if hostStdinWriter != nil {
_ = hostStdinWriter.Close()
}
// hostStdoutReader and wasmStdoutWriter handled below
}
}()
hostStdoutReader, wasmStdoutWriter, err := os.Pipe()
if err != nil {
return nil, nil, nil, fmt.Errorf("wasm stdout pipe: %w", err)
}
// Defer close pipes on error exit
defer func() {
if shouldClosePipesOnError {
if hostStdoutReader != nil {
_ = hostStdoutReader.Close()
}
if wasmStdoutWriter != nil {
_ = wasmStdoutWriter.Close()
}
}
}()
// Use the SHARDED runtime from the agent struct
runtime, ok := w.wasmRuntime.(wazero.Runtime)
if !ok || runtime == nil {
return nil, nil, nil, errors.New("wasmRuntime is not initialized or not a wazero.Runtime")
}
// Prepare module configuration (host funcs/WASI already instantiated on runtime)
config := wazero.NewModuleConfig().
WithStdin(wasmStdinReader).
WithStdout(wasmStdoutWriter).
WithStderr(os.Stderr).
WithArgs(McpServerPath).
WithFS(os.DirFS("/")) // Keep FS access for now
log.Info(ctx, "Instantiating pre-compiled WASM module (will run _start)...")
var moduleInstance api.Module
instanceErrChan := make(chan error, 1)
go func() {
var instantiateErr error
// Use context.Background() for the module's main execution context
moduleInstance, instantiateErr = runtime.InstantiateModule(context.Background(), w.preCompiledModule, config)
instanceErrChan <- instantiateErr
}()
// Wait briefly for immediate instantiation errors
select {
case instantiateErr := <-instanceErrChan:
if instantiateErr != nil {
log.Error(ctx, "Failed to instantiate pre-compiled WASM module", "error", instantiateErr)
// Pipes closed by defer
return nil, nil, nil, fmt.Errorf("instantiate wasm module: %w", instantiateErr)
}
log.Warn(ctx, "WASM module instantiation returned (exited?) immediately without error.")
case <-time.After(1 * time.Second): // Shorter wait now, instantiation should be faster
log.Debug(ctx, "WASM module instantiation likely blocking (server running), proceeding...")
}
// Start a monitoring goroutine for WASM module exit/error
go func(instanceToMonitor api.Module, errChan chan error) {
// This blocks until the instance created by InstantiateModule exits or errors.
instantiateErr := <-errChan
w.mu.Lock() // Lock the specific MCPWasm instance
log.Warn("WASM module exited/errored", "error", instantiateErr)
// Critical: Check if the agent's current module is STILL the one we were monitoring.
if w.wasmModule == instanceToMonitor {
w.cleanupResources_locked() // Use the locked version
log.Info("MCP WASM agent state cleaned up after module exit/error")
} else {
log.Debug("WASM module exited, but state already updated/module mismatch. No cleanup needed by this goroutine.")
// No need to close anything here, the pre-compiled module is shared
}
w.mu.Unlock()
}(moduleInstance, instanceErrChan)
// Success: prevent deferred cleanup of pipes
shouldClosePipesOnError = false
return hostStdinWriter, hostStdoutReader, moduleInstance, nil
}

View File

@@ -1,4 +1,4 @@
package deezer
package mcp_test
import (
"testing"
@@ -9,9 +9,9 @@ import (
. "github.com/onsi/gomega"
)
func TestDeezer(t *testing.T) {
func TestMCPAgent(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Deezer Test Suite")
RunSpecs(t, "MCP Agent Test Suite")
}

View File

@@ -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.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks)
}
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
@@ -109,40 +109,15 @@ 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, pls.Name, format, bitrate, out, mfs, true)
return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs)
}
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) 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)

View File

@@ -145,21 +145,9 @@ 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(3))
Expect(len(zr.File)).To(Equal(2))
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))
})
})
})

View File

@@ -115,7 +115,7 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider)
artReader, err = newArtistReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:

View File

@@ -4,11 +4,7 @@ import (
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -19,11 +15,11 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("Artwork", func() {
// TODO Fix tests
var _ = XDescribe("Artwork", func() {
var aw *artwork
var ds model.DataStore
var ffmpeg *tests.MockFFmpeg
var folderRepo *fakeFolderRepo
ctx := log.NewContext(context.TODO())
var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album
var arMultipleCovers model.Artist
@@ -34,21 +30,20 @@ var _ = Describe("Artwork", func() {
conf.Server.ImageCacheSize = "0" // Disable cache
conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*"
folderRepo = &fakeFolderRepo{}
ds = &tests.MockDataStore{
MockedTranscoding: &tests.MockTranscodingRepo{},
MockedFolder: folderRepo,
}
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}}
alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}}
alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}}
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"}
alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"}
//alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"}
//alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"}
arMultipleCovers = model.Artist{ID: "777", Name: "All options"}
alMultipleCovers = model.Album{
ID: "666",
Name: "All options",
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
FolderIDs: []string{"f1"},
ID: "666",
Name: "All options",
EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3",
//Paths: []string{"tests/fixtures/artist/an-album"},
//ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp +
// "tests/fixtures/artist/an-album/front.png" + consts.Zwsp +
// "tests/fixtures/artist/an-album/artist.png",
AlbumArtistID: "777",
}
mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"}
@@ -70,7 +65,6 @@ var _ = Describe("Artwork", func() {
})
Context("Embed images", func() {
BeforeEach(func() {
folderRepo.result = nil
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alEmbedNotFound,
@@ -93,17 +87,12 @@ var _ = Describe("Artwork", func() {
})
Context("External images", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyExternal,
alExternalNotFound,
})
})
It("returns external cover", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
}}
aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
@@ -111,7 +100,6 @@ var _ = Describe("Artwork", func() {
Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png"))
})
It("returns ErrUnavailable if external file is not available", func() {
folderRepo.result = []model.Folder{}
aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, _, err = aw.Reader(ctx)
@@ -120,10 +108,6 @@ var _ = Describe("Artwork", func() {
})
Context("Multiple covers", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg", "front.png", "artist.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
@@ -146,10 +130,6 @@ var _ = Describe("Artwork", func() {
Describe("artistArtworkReader", func() {
Context("Multiple covers", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"artist.png"},
}}
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{
arMultipleCovers,
})
@@ -163,7 +143,7 @@ var _ = Describe("Artwork", func() {
DescribeTable("ArtistArtPriority",
func(priority string, expected string) {
conf.Server.ArtistArtPriority = priority
aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil)
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
@@ -177,16 +157,12 @@ var _ = Describe("Artwork", func() {
Describe("mediafileArtworkReader", func() {
Context("ID not found", func() {
It("returns ErrNotFound if mediafile is not in the DB", func() {
_, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND"))
_, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Context("Embed images", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"front.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alOnlyEmbed,
alOnlyExternal,
@@ -209,17 +185,11 @@ var _ = Describe("Artwork", func() {
Expect(err).ToNot(HaveOccurred())
r, path, err := aw.Reader(ctx)
Expect(err).ToNot(HaveOccurred())
data, _ := io.ReadAll(r)
Expect(data).ToNot(BeEmpty())
Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg")))
Expect(path).To(Equal("tests/fixtures/test.ogg"))
})
It("returns album cover if cannot read embed artwork", func() {
// Force fromTag to fail
mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg"
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed())
// Simulate ffmpeg error
ffmpeg.Error = errors.New("not available")
aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID())
Expect(err).ToNot(HaveOccurred())
_, path, err := aw.Reader(ctx)
@@ -237,10 +207,6 @@ var _ = Describe("Artwork", func() {
})
Describe("resizedArtworkReader", func() {
BeforeEach(func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg", "front.png"},
}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alMultipleCovers,
})
@@ -275,13 +241,12 @@ var _ = Describe("Artwork", func() {
DescribeTable("resize",
func(format string, landscape bool, size int) {
coverFileName := "cover." + format
dirName := createImage(format, landscape, size)
//dirName := createImage(format, landscape, size)
alCover = model.Album{
ID: "444",
Name: "Only external",
FolderIDs: []string{"tmp"},
ID: "444",
Name: "Only external",
//ImageFiles: filepath.Join(dirName, coverFileName),
}
folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}}
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{
alCover,
})
@@ -305,24 +270,24 @@ var _ = Describe("Artwork", func() {
})
})
func createImage(format string, landscape bool, size int) string {
var img image.Image
if landscape {
img = image.NewRGBA(image.Rect(0, 0, size, size/2))
} else {
img = image.NewRGBA(image.Rect(0, 0, size/2, size))
}
tmpDir := GinkgoT().TempDir()
f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
defer f.Close()
switch format {
case "png":
_ = png.Encode(f, img)
case "jpg":
_ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
}
return tmpDir
}
//func createImage(format string, landscape bool, size int) string {
// var img image.Image
//
// if landscape {
// img = image.NewRGBA(image.Rect(0, 0, size, size/2))
// } else {
// img = image.NewRGBA(image.Rect(0, 0, size/2, size))
// }
//
// tmpDir := GinkgoT().TempDir()
// f, _ := os.Create(filepath.Join(tmpDir, "cover."+format))
// defer f.Close()
// switch format {
// case "png":
// _ = png.Encode(f, img)
// case "jpg":
// _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75})
// }
//
// return tmpDir
//}

View File

@@ -31,12 +31,6 @@ func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer {
return &noopCacheWarmer{}
}
// If the file cache is disabled, return a NOOP implementation
if cache.Disabled(context.Background()) {
log.Debug("Image cache disabled. Cache warmer will not run")
return &noopCacheWarmer{}
}
a := &cacheWarmer{
artwork: artwork,
cache: cache,
@@ -59,9 +53,6 @@ type cacheWarmer struct {
}
func (a *cacheWarmer) PreCache(artID model.ArtworkID) {
if a.cache.Disabled(context.Background()) {
return
}
a.mutex.Lock()
defer a.mutex.Unlock()
a.buffer[artID] = struct{}{}
@@ -83,17 +74,6 @@ func (a *cacheWarmer) run(ctx context.Context) {
break
}
if a.cache.Disabled(ctx) {
a.mutex.Lock()
pending := len(a.buffer)
a.buffer = make(map[model.ArtworkID]struct{})
a.mutex.Unlock()
if pending > 0 {
log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending)
}
return
}
// If cache not available, keep waiting
if !a.cache.Available(ctx) {
if len(a.buffer) > 0 {

View File

@@ -1,216 +0,0 @@
package artwork
import (
"context"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("CacheWarmer", func() {
var (
fc *mockFileCache
aw *mockArtwork
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
fc = &mockFileCache{}
aw = &mockArtwork{}
})
Context("initialization", func() {
It("returns noop when cache is disabled", func() {
fc.SetDisabled(true)
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns noop when ImageCacheSize is 0", func() {
conf.Server.ImageCacheSize = "0"
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns noop when EnableArtworkPrecache is false", func() {
conf.Server.EnableArtworkPrecache = false
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*noopCacheWarmer)
Expect(ok).To(BeTrue())
})
It("returns real implementation when properly configured", func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
cw := NewCacheWarmer(aw, fc)
_, ok := cw.(*cacheWarmer)
Expect(ok).To(BeTrue())
})
})
Context("buffer management", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("drops buffered items when cache becomes disabled", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-test"))
fc.SetDisabled(true)
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("adds multiple items to buffer", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-2"))
cw.mutex.Lock()
defer cw.mutex.Unlock()
Expect(len(cw.buffer)).To(Equal(2))
})
It("deduplicates items in buffer", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.mutex.Lock()
defer cw.mutex.Unlock()
Expect(len(cw.buffer)).To(Equal(1))
})
})
Context("error handling", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("continues processing after artwork retrieval error", func() {
aw.err = errors.New("artwork error")
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-error"))
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("continues processing after cache error", func() {
fc.err = errors.New("cache error")
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-error"))
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
})
Context("background processing", func() {
BeforeEach(func() {
conf.Server.ImageCacheSize = "100MB"
conf.Server.EnableArtworkPrecache = true
fc.SetDisabled(false)
})
It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
for i := 0; i < 5; i++ {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
}
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
It("wakes up on new items", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
// Add first batch
cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
// Add second batch
cw.PreCache(model.MustParseArtworkID("al-2"))
Eventually(func() int {
cw.mutex.Lock()
defer cw.mutex.Unlock()
return len(cw.buffer)
}).Should(Equal(0))
})
})
})
type mockArtwork struct {
err error
}
func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) {
if m.err != nil {
return nil, time.Time{}, m.err
}
return io.NopCloser(strings.NewReader("test")), time.Now(), nil
}
func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) {
return m.Get(ctx, model.ArtworkID{}, size, square)
}
type mockFileCache struct {
disabled atomic.Bool
ready atomic.Bool
err error
}
func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) {
if f.err != nil {
return nil, f.err
}
return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil
}
func (f *mockFileCache) Available(ctx context.Context) bool {
return f.ready.Load() && !f.disabled.Load()
}
func (f *mockFileCache) Disabled(ctx context.Context) bool {
return f.disabled.Load()
}
func (f *mockFileCache) SetDisabled(v bool) {
f.disabled.Store(v)
f.ready.Store(true)
}

View File

@@ -20,12 +20,6 @@ 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
@@ -35,7 +29,7 @@ type artistReader struct {
imgFiles []string
}
func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
if err != nil {
return nil, err
@@ -114,52 +108,36 @@ 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) {
current := artistFolder
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
}
parent := filepath.Dir(current)
if parent == current {
break
}
current = parent
}
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)
fsys := os.DirFS(artistFolder)
matches, err := fs.Glob(fsys, pattern)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
continue
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder)
return nil, "", err
}
return f, filePath, nil
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
}
f, err := os.Open(filePath)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
return nil, "", err
}
return f, filePath, nil
}
return nil, "", 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 - for now! TODO: Support multiple libraries
libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library
folderPath := str.LongestCommonPrefix(paths)
if !strings.HasSuffix(folderPath, string(filepath.Separator)) {

View File

@@ -3,8 +3,6 @@ package artwork
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"time"
@@ -14,7 +12,7 @@ import (
. "github.com/onsi/gomega"
)
var _ = Describe("artistArtworkReader", func() {
var _ = Describe("artistReader", func() {
var _ = Describe("loadArtistFolder", func() {
var (
ctx context.Context
@@ -110,254 +108,6 @@ 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 {

View File

@@ -190,13 +190,10 @@ type mockAgents struct {
topSongsAgent agents.ArtistTopSongsRetriever
similarAgent agents.ArtistSimilarRetriever
imageAgent agents.ArtistImageRetriever
albumInfoAgent interface {
agents.AlbumInfoRetriever
agents.AlbumImageRetriever
}
bioAgent agents.ArtistBiographyRetriever
mbidAgent agents.ArtistMBIDRetriever
urlAgent agents.ArtistURLRetriever
albumInfoAgent agents.AlbumInfoRetriever
bioAgent agents.ArtistBiographyRetriever
mbidAgent agents.ArtistMBIDRetriever
urlAgent agents.ArtistURLRetriever
agents.Interface
}
@@ -271,14 +268,3 @@ 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)
}

View File

@@ -3,7 +3,6 @@ package external
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strings"
@@ -12,9 +11,9 @@ 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/mcp"
_ "github.com/navidrome/navidrome/core/agents/spotify"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -36,7 +35,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)
ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error)
SimilarSongs(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)
@@ -61,7 +60,6 @@ type auxArtist struct {
type Agents interface {
agents.AlbumInfoRetriever
agents.AlbumImageRetriever
agents.ArtistBiographyRetriever
agents.ArtistMBIDRetriever
agents.ArtistImageRetriever
@@ -142,20 +140,19 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description
}
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
if len(info.Images) > 0 {
sort.Slice(info.Images, func(i, j int) bool {
return info.Images[i].Size > info.Images[j].Size
})
album.LargeImageUrl = images[0].URL
album.LargeImageUrl = info.Images[0].URL
if len(images) >= 2 {
album.MediumImageUrl = images[1].URL
if len(info.Images) >= 2 {
album.MediumImageUrl = info.Images[1].URL
}
if len(images) >= 3 {
album.SmallImageUrl = images[2].URL
if len(info.Images) >= 3 {
album.SmallImageUrl = info.Images[2].URL
}
}
@@ -261,7 +258,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, nil
}
func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
@@ -269,14 +266,14 @@ func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
log.Warn(ctx, "SimilarSongs 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, "ArtistRadio call canceled", ctx.Err())
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
return ctx.Err()
}
@@ -344,28 +341,29 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
info, err := e.ag.GetAlbumInfo(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, "GetAlbumImages call canceled", err)
log.Debug(ctx, "GetAlbumInfo call canceled", err)
default:
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
}
return nil, err
}
if len(images) == 0 {
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
if info == nil {
log.Warn(ctx, "Agent returned nil info 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 images {
for _, i := range info.Images {
if img.Size <= i.Size {
img = i
}
@@ -403,21 +401,20 @@ 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, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err)
return nil, err
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", 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
}
}
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 {
@@ -427,94 +424,35 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return mfs, 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)
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
}
return e.findMatchingTrack(ctx, "", artistID, title)
}
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{
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Or{
squirrel.Eq{"artist_id": artist.ID},
squirrel.Eq{"album_artist_id": artist.ID},
squirrel.Eq{"artist_id": artistID},
squirrel.Eq{"album_artist_id": artistID},
},
titleFilters,
squirrel.Like{"order_title": str.SanitizeFieldForSorting(title)},
squirrel.Eq{"missing": false},
},
Sort: "starred desc, rating desc, year asc, compilation asc ",
Max: 1,
})
if err != nil {
return matches, err
if err != nil || len(mfs) == 0 {
return nil, model.ErrNotFound
}
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
return &mfs[0], nil
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
@@ -560,7 +498,7 @@ func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimila
return
}
start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent)
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
@@ -568,7 +506,7 @@ func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimila
artist.SimilarArtists = sa
}
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
var result model.Artists
var notPresent []string
@@ -591,33 +529,21 @@ 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 && count < limit {
if includeNotPresent {
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
}
}
}

View File

@@ -23,6 +23,7 @@ var _ = Describe("Provider - AlbumImage", func() {
var mockAlbumRepo *mockAlbumRepo
var mockMediaFileRepo *mockMediaFileRepo
var mockAlbumAgent *mockAlbumInfoAgent
var agentsCombined *mockAgents
var ctx context.Context
BeforeEach(func() {
@@ -42,7 +43,10 @@ var _ = Describe("Provider - AlbumImage", func() {
mockAlbumAgent = newMockAlbumInfoAgent()
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
agentsCombined = &mockAgents{
albumInfoAgent: mockAlbumAgent,
}
provider = NewProvider(ds, agentsCombined)
// Default mocks
@@ -62,11 +66,13 @@ 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("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},
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},
},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -76,8 +82,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(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name
})
It("returns ErrNotFound if the album is not found in the DB", func() {
@@ -93,7 +99,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(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
It("returns the agent error if the agent fails", func() {
@@ -103,7 +109,7 @@ var _ = Describe("Provider - AlbumImage", func() {
agentErr := errors.New("agent failure")
// Explicitly mock agent call for this test
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -112,7 +118,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(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
})
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
@@ -121,7 +127,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("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -129,7 +135,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(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
})
It("returns ErrNotFound if the agent returns no images", func() {
@@ -138,8 +144,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("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist
imgURL, err := provider.AlbumImage(ctx, "album-1")
@@ -147,7 +153,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(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
})
It("returns context error if context is canceled", func() {
@@ -157,7 +163,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("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
mockAlbumAgent.On("GetAlbumInfo", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
// Cancel the context *before* calling the function under test
cancelCtx()
@@ -168,7 +174,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(), "GetAlbumImages", cctx, "Album One", "", "")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", cctx, "Album One", "", "")
})
It("derives album ID from MediaFile ID", func() {
@@ -180,11 +186,13 @@ 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("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},
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},
},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -198,7 +206,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(), "GetAlbumImages", ctx, "Album One", "", "")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
})
It("handles different image orders from agent", func() {
@@ -206,11 +214,13 @@ 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("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},
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},
},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/large.jpg")
@@ -218,7 +228,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
})
It("handles agent returning only one image", func() {
@@ -226,9 +236,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("GetAlbumImages", ctx, "Album One", "", "").
Return([]agents.ExternalImage{
{URL: "http://example.com/single.jpg", Size: 700},
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{
Images: []agents.ExternalImage{
{URL: "http://example.com/single.jpg", Size: 700},
},
}, nil).Once()
expectedURL, _ := url.Parse("http://example.com/single.jpg")
@@ -236,7 +248,7 @@ var _ = Describe("Provider - AlbumImage", func() {
Expect(err).ToNot(HaveOccurred())
Expect(imgURL).To(Equal(expectedURL))
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
})
It("returns ErrNotFound if deriving album ID fails", func() {
@@ -258,15 +270,14 @@ 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(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
})
// mockAlbumInfoAgent implementation
type mockAlbumInfoAgent struct {
mock.Mock
agents.AlbumInfoRetriever
agents.AlbumImageRetriever
agents.AlbumInfoRetriever // Embed interface
}
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
@@ -288,14 +299,5 @@ func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbi
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
}
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
// Ensure mockAgent implements the interface
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)

View File

@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - ArtistRadio", func() {
var _ = Describe("Provider - SimilarSongs", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
@@ -50,9 +50,9 @@ var _ = Describe("Provider - ArtistRadio", 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", 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"}
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"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
@@ -82,10 +82,11 @@ var _ = Describe("Provider - ArtistRadio", func() {
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
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()
mediaFileRepo.FindByMBID("mbid-1", song1)
mediaFileRepo.FindByMBID("mbid-2", song2)
mediaFileRepo.FindByMBID("mbid-3", song3)
songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
@@ -102,7 +103,7 @@ var _ = Describe("Provider - ArtistRadio", func() {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
@@ -110,7 +111,7 @@ var _ = Describe("Provider - ArtistRadio", 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", MbzRecordingID: "mbid-1"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
@@ -129,9 +130,9 @@ var _ = Describe("Provider - ArtistRadio", func() {
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
mediaFileRepo.FindByMBID("mbid-1", song1)
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
@@ -156,7 +157,7 @@ var _ = Describe("Provider - ArtistRadio", func() {
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
@@ -164,8 +165,8 @@ var _ = Describe("Provider - ArtistRadio", 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", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
@@ -185,9 +186,10 @@ var _ = Describe("Provider - ArtistRadio", func() {
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
mediaFileRepo.FindByMBID("mbid-1", song1)
mediaFileRepo.FindByMBID("mbid-2", song2)
songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))

View File

@@ -42,6 +42,10 @@ 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"}
@@ -54,10 +58,11 @@ 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 (both returned in a single query)
// Mock finding matching tracks
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()
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()
songs, err := p.TopSongs(ctx, "Artist One", 2)
@@ -150,10 +155,11 @@ 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 on bulk query)
// Mock finding matching tracks (only find song 1)
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() // bulk MBID query
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2
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)
songs, err := p.TopSongs(ctx, "Artist One", 2)
@@ -184,91 +190,4 @@ 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())
})
})

View File

@@ -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,6 +74,9 @@ 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))

View File

@@ -1,37 +0,0 @@
package lyrics
import (
"context"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
var lyricsList model.LyricList
var err error
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":
lyricsList, err = fromEmbedded(ctx, mf)
case strings.HasPrefix(pattern, "."):
lyricsList, err = fromExternalFile(ctx, mf, pattern)
default:
log.Error(ctx, "Invalid lyric pattern", "pattern", pattern)
}
if err != nil {
log.Error(ctx, "error parsing lyrics", "source", pattern, err)
}
if len(lyricsList) > 0 {
return lyricsList, nil
}
}
return nil, nil
}

View File

@@ -1,17 +0,0 @@
package lyrics_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLyrics(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Lyrics Suite")
}

View File

@@ -1,124 +0,0 @@
package lyrics_test
import (
"context"
"encoding/json"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sources", func() {
var mf model.MediaFile
var ctx context.Context
const badLyrics = "This is a set of lyrics\nThat is not good"
unsynced, _ := model.ToLyrics("xxx", badLyrics)
embeddedLyrics := model.LyricList{*unsynced}
syncedLyrics := model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
Synced: true,
},
}
unsyncedLyrics := model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
Synced: false,
},
}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
lyricsJson, _ := json.Marshal(embeddedLyrics)
mf = model.MediaFile{
Lyrics: string(lyricsJson),
Path: "tests/fixtures/test.mp3",
}
ctx = context.Background()
})
DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) {
conf.Server.LyricsPriority = priority
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(expected))
},
Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics),
Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics),
Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics))
Context("Errors", func() {
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
RegularUserContext("run without root permissions", func() {
var accessForbiddenFile string
BeforeEach(func() {
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
mf.Path = accessForbiddenFile
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("should fallback to embedded if an error happens when parsing file", func() {
conf.Server.LyricsPriority = ".mp3,embedded"
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics))
})
It("should return nothing if error happens when trying to parse file", func() {
conf.Server.LyricsPriority = ".mp3"
list, err := lyrics.GetLyrics(ctx, &mf)
Expect(err).To(BeNil())
Expect(list).To(BeEmpty())
})
})
})
})

View File

@@ -1,51 +0,0 @@
package lyrics
import (
"context"
"errors"
"os"
"path"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
if mf.Lyrics != "" {
log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title)
return mf.StructuredLyrics()
}
log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title)
return nil, nil
}
func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) {
basePath := mf.AbsolutePath()
ext := path.Ext(basePath)
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
contents, err := os.ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
return nil, nil
} else if err != nil {
return nil, err
}
lyrics, err := model.ToLyrics("xxx", string(contents))
if err != nil {
log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err)
return nil, err
} else if lyrics == nil {
log.Trace(ctx, "empty lyrics from external file", "path", externalLyric)
return nil, nil
}
log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric)
return model.LyricList{*lyrics}, nil
}

View File

@@ -1,112 +0,0 @@
package lyrics
import (
"context"
"encoding/json"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("sources", func() {
ctx := context.Background()
Describe("fromEmbedded", func() {
It("should return nothing for a media file with no lyrics", func() {
mf := model.MediaFile{}
lyrics, err := fromEmbedded(ctx, &mf)
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(0))
})
It("should return lyrics for a media file with well-formatted lyrics", 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"
synced, _ := model.ToLyrics("eng", syncedLyrics)
unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics)
expectedList := model.LyricList{*synced, *unsynced}
lyricsJson, err := json.Marshal(expectedList)
Expect(err).ToNot(HaveOccurred())
mf := model.MediaFile{
Lyrics: string(lyricsJson),
}
lyrics, err := fromEmbedded(ctx, &mf)
Expect(err).To(BeNil())
Expect(lyrics).ToNot(BeNil())
Expect(lyrics).To(Equal(expectedList))
})
It("should return an error if somehow the JSON is bad", func() {
mf := model.MediaFile{Lyrics: "["}
lyrics, err := fromEmbedded(ctx, &mf)
Expect(lyrics).To(HaveLen(0))
Expect(err).ToNot(BeNil())
})
})
Describe("fromExternalFile", func() {
It("should return nil for lyrics that don't exist", func() {
mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).To(HaveLen(0))
})
It("should return synchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
DisplayArtist: "Rick Astley",
DisplayTitle: "That one song",
Lang: "eng",
Line: []model.Line{
{
Start: gg.P(int64(18800)),
Value: "We're no strangers to love",
},
{
Start: gg.P(int64(22801)),
Value: "You know the rules and so do I",
},
},
Offset: gg.P(int64(-100)),
Synced: true,
},
}))
})
It("should return unsynchronized lyrics from a file", func() {
mf := model.MediaFile{Path: "tests/fixtures/test.mp3"}
lyrics, err := fromExternalFile(ctx, &mf, ".txt")
Expect(err).To(BeNil())
Expect(lyrics).To(Equal(model.LyricList{
model.Lyrics{
Lang: "xxx",
Line: []model.Line{
{
Value: "We're no strangers to love",
},
{
Value: "You know the rules and so do I",
},
},
Synced: false,
},
}))
})
})
})

View File

@@ -176,7 +176,6 @@ 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

View File

@@ -60,7 +60,6 @@ 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"`

View File

@@ -2,6 +2,7 @@ package metrics
import (
"context"
"fmt"
"net/http"
"strconv"
"sync"
@@ -12,7 +13,6 @@ 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,8 +20,6 @@ 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
}
@@ -29,14 +27,11 @@ type metrics struct {
ds model.DataStore
}
func GetPrometheusInstance(ds model.DataStore) Metrics {
if !conf.Server.Prometheus.Enabled {
return noopMetrics{}
}
return singleton.GetInstance(func() *metrics {
func NewPrometheusInstance(ds model.DataStore) Metrics {
if conf.Server.Prometheus.Enabled {
return &metrics{ds: ds}
})
}
return noopMetrics{}
}
func NewNoopInstance() Metrics {
@@ -56,38 +51,6 @@ 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()
@@ -96,31 +59,20 @@ 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
httpRequestCounter *prometheus.CounterVec
httpRequestDuration *prometheus.SummaryVec
pluginRequestCounter *prometheus.CounterVec
pluginRequestDuration *prometheus.SummaryVec
dbTotal *prometheus.GaugeVec
versionInfo *prometheus.GaugeVec
lastMediaScan *prometheus.GaugeVec
mediaScansCounter *prometheus.CounterVec
}
// 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{
@@ -150,49 +102,23 @@ 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"},
),
}
prometheus.DefaultRegisterer.MustRegister(
instance.dbTotal,
instance.versionInfo,
instance.lastMediaScan,
instance.mediaScansCounter,
instance.httpRequestCounter,
instance.httpRequestDuration,
instance.pluginRequestCounter,
instance.pluginRequestDuration,
)
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))
}
return instance
})
@@ -233,8 +159,4 @@ 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 }

View File

@@ -10,15 +10,11 @@ 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()
@@ -75,32 +71,28 @@ func (j *Executor) wait() {
// Path will always be an absolute path
func createMPVCommand(deviceName string, filename string, socketName string) []string {
// 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
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
}
return split
}
// 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
}
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)
}
}
return templateArgs
return strings.Join(result, " ")
}
// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified.

View File

@@ -1,17 +0,0 @@
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")
}

View File

@@ -1,390 +0,0 @@
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
}

View File

@@ -34,10 +34,7 @@ func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName str
tmpSocketName := socketName("mpv-ctrl-", ".socket")
args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName)
if len(args) == 0 {
return nil, fmt.Errorf("no mpv command arguments provided")
}
args := createMPVCommand(deviceName, mf.Path, tmpSocketName)
exe, err := start(ctx, args)
if err != nil {
log.Error("Error starting mpv process", err)

View File

@@ -10,16 +10,9 @@ import (
)
func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler {
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)
b := &bufferedScrobbler{ds: ds, wrapped: s, service: service}
b.wakeSignal = make(chan struct{}, 1)
go b.run(context.TODO())
return b
}
@@ -28,22 +21,14 @@ 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, position int) error {
return b.wrapped.NowPlaying(ctx, userId, track, position)
func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error {
return b.wrapped.NowPlaying(ctx, userId, track)
}
func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {

View File

@@ -1,88 +0,0 @@
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")
}
})
})

View File

@@ -21,7 +21,7 @@ var (
type Scrobbler interface {
IsAuthorized(ctx context.Context, userId string) bool
NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error
NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error
Scrobble(ctx context.Context, userId string, s Scrobble) error
}

View File

@@ -2,9 +2,7 @@ package scrobbler
import (
"context"
"maps"
"sort"
"sync"
"time"
"github.com/navidrome/navidrome/conf"
@@ -20,7 +18,6 @@ import (
type NowPlayingInfo struct {
MediaFile model.MediaFile
Start time.Time
Position int
Username string
PlayerId string
PlayerName string
@@ -32,53 +29,30 @@ type Submission struct {
}
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
Submit(ctx context.Context, submissions []Submission) error
}
// 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)
}
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
ds model.DataStore
broker events.Broker
playMap cache.SimpleCache[string, NowPlayingInfo]
scrobblers map[string]Scrobbler
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker {
return singleton.GetInstance(func() *playTracker {
return newPlayTracker(ds, broker, pluginManager)
return newPlayTracker(ds, broker)
})
}
// 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, pluginManager PluginLoader) *playTracker {
func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker {
m := cache.NewSimpleCache[string, NowPlayingInfo]()
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()})
})
}
p := &playTracker{ds: ds, playMap: m, broker: broker}
p.scrobblers = make(map[string]Scrobbler)
var enabled []string
for name, constructor := range constructors {
s := constructor(ds)
@@ -87,93 +61,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
continue
}
enabled = append(enabled, name)
s = newBufferedScrobbler(ds, s, name)
p.builtinScrobblers[name] = s
if conf.Server.DevEnableBufferedScrobble {
s = newBufferedScrobbler(ds, s, name)
}
p.scrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
log.Debug("List of scrobblers enabled", "names", enabled)
return p
}
// 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 {
func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error {
mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId)
if err != nil {
log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err)
@@ -184,44 +81,31 @@ 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,
}
// 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
ttl := time.Duration(int(mf.Duration)+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, position)
p.dispatchNowPlaying(ctx, user.ID, mf)
}
return nil
}
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) {
func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) {
if t.Artist == consts.UnknownArtist {
log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist)
return
}
allScrobblers := p.getActiveScrobblers()
for name, s := range allScrobblers {
for name, s := range p.scrobblers {
if !s.IsAuthorized(ctx, userId) {
continue
}
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position)
err := s.NowPlaying(ctx, userId, t, position)
log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist)
err := s.NowPlaying(ctx, userId, t)
if err != nil {
log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)
continue
@@ -293,15 +177,17 @@ 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 allScrobblers {
for name, s := range p.scrobblers {
if !s.IsAuthorized(ctx, u.ID) {
continue
}
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
if conf.Server.DevEnableBufferedScrobble {
log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
} else {
log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist)
}
err := s.Scrobble(ctx, u.ID, scrobble)
if err != nil {
log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err)

View File

@@ -3,13 +3,9 @@ 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"
@@ -20,28 +16,10 @@ 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
@@ -49,7 +27,9 @@ var _ = Describe("PlayTracker", func() {
var fake fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Remove buffering to simplify tests
conf.Server.DevEnableBufferedScrobble = false
ctx = context.Background()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
@@ -61,9 +41,7 @@ var _ = Describe("PlayTracker", func() {
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
tracker = newPlayTracker(ds, events.GetBroker())
track = model.MediaFile{
ID: "123",
@@ -87,13 +65,13 @@ var _ = Describe("PlayTracker", func() {
})
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
Expect(tracker.(*playTracker).scrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).scrobblers).ToNot(HaveKey("disabled"))
})
Describe("NowPlaying", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
@@ -103,7 +81,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", 0)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
@@ -111,7 +89,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", 0)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
Expect(err).ToNot(HaveOccurred())
Expect(fake.NowPlayingCalled).To(BeFalse())
@@ -119,40 +97,11 @@ 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", 0)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123")
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() {
@@ -161,9 +110,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", 0)
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123")
ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456")
playing, err := tracker.GetNowPlaying(ctx)
@@ -181,26 +130,6 @@ 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"})
@@ -209,12 +138,10 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
Expect(fake.ScrobbleCalled).To(BeTrue())
Expect(fake.UserID).To(Equal("u-1"))
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))
Expect(fake.LastScrobble.ID).To(Equal("123"))
Expect(fake.LastScrobble.Participants).To(Equal(track.Participants))
})
It("increments play counts in the DB", func() {
@@ -238,7 +165,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
Expect(fake.ScrobbleCalled).To(BeFalse())
})
It("does not send track to agent if player is not enabled to send scrobbles", func() {
@@ -247,7 +174,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
Expect(fake.ScrobbleCalled).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
@@ -256,7 +183,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
Expect(fake.ScrobbleCalled).To(BeFalse())
})
It("increments play counts even if it cannot scrobble", func() {
@@ -265,7 +192,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}})
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeFalse())
Expect(fake.ScrobbleCalled).To(BeFalse())
Expect(track.PlayCount).To(Equal(int64(1)))
Expect(album.PlayCount).To(Equal(int64(1)))
@@ -276,111 +203,15 @@ 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 atomic.Bool
ScrobbleCalled bool
UserID string
Track *model.MediaFile
Position int
LastScrobble atomic.Pointer[Scrobble]
LastScrobble Scrobble
Error error
}
@@ -388,24 +219,23 @@ 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, position int) error {
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) 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.UserID = userId
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
f.ScrobbleCalled = true
if f.Error != nil {
return f.Error
}
f.UserID = userId
f.LastScrobble = s
return nil
}
@@ -416,45 +246,3 @@ 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)
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
@@ -94,7 +93,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
}
s.ID = id
if V(s.ExpiresAt).IsZero() {
s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration))
s.ExpiresAt = P(time.Now().Add(365 * 24 * time.Hour))
}
firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0]

View File

@@ -13,7 +13,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/run"
"github.com/navidrome/navidrome/utils/chain"
"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 run.Sequentially(
return chain.RunSequentially(
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 run.Sequentially(
return chain.RunSequentially(
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 run.Sequentially(
return chain.RunSequentially(
execute(`
alter table artist
drop column album_count;

View File

@@ -1,36 +0,0 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE share_tmp
(
id varchar(255) not null
primary key,
expires_at datetime,
last_visited_at datetime,
resource_ids varchar not null,
created_at datetime,
updated_at datetime,
user_id varchar(255) not null
constraint share_user_id_fk
references user
on update cascade on delete cascade,
downloadable bool not null default false,
description varchar not null default '',
resource_type varchar not null default '',
contents varchar not null default '',
format varchar not null default '',
max_bit_rate integer not null default 0,
visit_count integer not null default 0
);
INSERT INTO share_tmp(
id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count
FROM share;
DROP TABLE share;
ALTER TABLE share_tmp RENAME To share;
-- +goose StatementEnd
-- +goose Down

View File

@@ -1,80 +0,0 @@
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, &currentID, &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
}

View File

@@ -1,21 +0,0 @@
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
}

View File

@@ -1,46 +0,0 @@
-- +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

View File

@@ -1,48 +0,0 @@
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
}

View File

@@ -1,49 +0,0 @@
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
}

View File

@@ -1,7 +0,0 @@
-- +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

View File

@@ -1,65 +0,0 @@
-- +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

View File

@@ -1,27 +0,0 @@
-- +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

View File

@@ -12,7 +12,7 @@
gofmtcmd="go tool goimports"
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$' | grep -v '.pb.go$')
gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$')
[ -z "$gofiles" ] && exit 0
unformatted=$($gofmtcmd -l $gofiles)

72
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
go 1.24.4
go 1.24.2
// 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.2
github.com/go-chi/chi/v5 v5.2.1
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,24 +31,22 @@ 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.4.0
github.com/jellydator/ttlcache/v3 v3.3.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.6
github.com/lestrrat-go/jwx/v2 v2.1.4
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/mattn/go-sqlite3 v1.14.27
github.com/metoro-io/mcp-golang v0.11.0
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.3
github.com/prometheus/client_golang v1.22.0
github.com/pressly/goose/v3 v3.24.2
github.com/prometheus/client_golang v1.21.1
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
@@ -60,81 +58,85 @@ require (
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-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
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
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/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cespare/reflex v0.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/creack/pty v1.1.11 // indirect
github.com/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.3 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // 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-20250607225305-033d6d78b36a // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // 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/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/invopop/jsonschema v0.12.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/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.4 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // 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/mailru/easyjson v0.7.7 // 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.1 // indirect
github.com/prometheus/procfs v0.16.0 // 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.9.2 // indirect
github.com/spf13/cast v1.7.1 // 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/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // 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.39.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/tools v0.34.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
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

150
go.sum
View File

@@ -1,5 +1,3 @@
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=
@@ -8,16 +6,18 @@ 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/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38=
github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -26,7 +26,6 @@ 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=
@@ -60,29 +59,27 @@ 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.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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/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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
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-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-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.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/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=
@@ -92,8 +89,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-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
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/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=
@@ -104,8 +101,6 @@ 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=
@@ -113,8 +108,11 @@ 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.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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=
@@ -123,10 +121,8 @@ 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.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/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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=
@@ -141,32 +137,34 @@ 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.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
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/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.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/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/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
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=
@@ -182,7 +180,6 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
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=
@@ -190,16 +187,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.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/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/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.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
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=
@@ -214,8 +211,6 @@ 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=
@@ -227,14 +222,12 @@ 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.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
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=
@@ -245,7 +238,6 @@ 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=
@@ -257,8 +249,20 @@ 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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
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/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -279,21 +283,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.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/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/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
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.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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/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=
@@ -306,8 +310,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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
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=
@@ -315,8 +319,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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.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=
@@ -335,8 +339,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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.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=
@@ -357,10 +361,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.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/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/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=
@@ -369,8 +373,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.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
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/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=
@@ -386,11 +390,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.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
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.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=
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=

View File

@@ -203,10 +203,6 @@ 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

View File

@@ -42,9 +42,8 @@ func (h *Hook) Fire(e *logrus.Entry) error {
e.Data[k] = "[REDACTED]"
continue
}
if v == nil {
continue
}
// Redact based on value matching in Data fields
switch reflect.TypeOf(v).Kind() {
case reflect.String:
e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2")

1
mcp-server/README.md Normal file
View File

@@ -0,0 +1 @@

View File

@@ -32,8 +32,6 @@ type Artist struct {
SimilarArtists Artists `structs:"similar_artists" json:"-"`
ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"`
Missing bool `structs:"missing" json:"missing"`
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
}
@@ -78,11 +76,11 @@ type ArtistRepository interface {
UpdateExternalInfo(a *Artist) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
GetIndex(includeMissing bool, roles ...Role) (ArtistIndexes, error)
GetIndex(roles ...Role) (ArtistIndexes, error)
// The following methods are used exclusively by the scanner:
RefreshPlayCounts() (int64, error)
RefreshStats(allArtists bool) (int64, error)
RefreshStats() (int64, error)
AnnotatedRepository
SearchableRepository[Artists]

Some files were not shown because too many files have changed in this diff Show More