Compare commits

...

26 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
Deluan
4944f8035a test: add tests for userName and AbsolutePath in core/common.go
Added Ginkgo/Gomega tests for userName and AbsolutePath functions in core/common.go. Tests cover normal and error cases, using existing mocks and helpers. This improves coverage and ensures correct behavior for user context extraction and library path resolution.
2025-04-18 11:53:47 -04:00
Ivan Pešić
0d5097d888 fix(ui): update Serbian translation (#3941) 2025-04-17 19:27:12 -04:00
Deluan Quintão
ed7ee3d9f8 fix(ui): always order album tracks by disc and track number (fixes #3720) (#3975)
* fix(ui): ensure album tracks are always ordered by disc and track number (fixes #3720)

* refactor(ui): remove obsolete release date grouping logic from SongDatagrid and AlbumSongs

* fix(ui): ensure correct album track ordering in context menu and play button

* fix: Update album sort to use album_id instead of release_date

* refactor: Adjust filters in PlayButton and AlbumContextMenu

* fix: correct typo in comment regarding participants in GetMissingAndMatching function

* fix: prevent visual separation of tracks on same disc

Removes the leftover `releaseDate` check from the `firstTracksOfDiscs` calculation in `SongDatagridBody`. This check caused unnecessary `DiscSubtitleRow` insertions when tracks on the same disc had different release dates, leading to an incorrect visual grouping that resembled a multi-disc layout.

This change ensures disc subtitles are only shown when the actual `discNumber` changes, correcting the UI presentation issue reported in issue #3720 after PR #3975.

* fix: remove remaining releaseDate references in SongDatagrid

Cleaned up leftover `releaseDate` references in `SongDatagrid.jsx`:

- Removed `releaseDate` parameter and usage from `handlePlaySubset` in `DiscSubtitleRow`.

- Removed `releaseDate` prop passed to `AlbumContextMenu` in `DiscSubtitleRow`.

- Removed `releaseDate` from the drag item data in `SongDatagridRow`.

- Removed `releaseDate` parameter and the corresponding `else` block from the `playSubset` function in `SongDatagridBody`.

This ensures the component consistently uses `discNumber` for grouping and playback actions initiated from the disc subtitle, fully resolving the inconsistencies related to issue #3720.
2025-04-17 19:23:53 -04:00
Deluan Quintão
74803bb43e fix(ui): update Russian, Turkish translations from POEditor (#3971)
Co-authored-by: navidrome-bot <navidrome-bot@navidrome.org>
2025-04-16 21:09:50 -04:00
marcbres
0159cf73e2 fix(ui): updated Catalan translations (#3973)
Co-authored-by: Marc Bres Gil <marc@helm>
2025-04-16 21:05:59 -04:00
Dongeun
ac1d51f9d0 fix(ui): update Chinese (Simplified) translations (#3938) 2025-04-16 21:05:26 -04:00
Thomas Johansen
91eb661db5 fix(ui): update Norwegian translation #3943 2025-04-16 21:04:10 -04:00
Guilherme Souza
524d508916 feat(ui): show sampleRate in song info dialog (#3960)
* feat(ui): show sampleRate in song info dialog

* npm run prettier --write
2025-04-12 20:52:47 -04:00
36 changed files with 3433 additions and 919 deletions

View File

@@ -45,7 +45,7 @@ func createAgents(ds model.DataStore) *Agents {
continue
}
enabled = append(enabled, name)
res = append(res, init(ds))
res = append(res, agent)
}
log.Debug("List of agents enabled", "names", enabled)

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

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

55
core/common_test.go Normal file
View File

@@ -0,0 +1,55 @@
package core
import (
"context"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
)
var _ = Describe("common.go", func() {
Describe("userName", func() {
It("returns the username from context", func() {
ctx := request.WithUser(context.Background(), model.User{UserName: "testuser"})
Expect(userName(ctx)).To(Equal("testuser"))
})
It("returns 'UNKNOWN' if no user in context", func() {
ctx := context.Background()
Expect(userName(ctx)).To(Equal("UNKNOWN"))
})
})
Describe("AbsolutePath", func() {
var (
ds *tests.MockDataStore
libId int
path string
)
BeforeEach(func() {
ds = &tests.MockDataStore{}
libId = 1
path = "music/file.mp3"
mockLib := &tests.MockLibraryRepo{}
mockLib.SetData(model.Libraries{{ID: libId, Path: "/library/root"}})
ds.MockedLibrary = mockLib
})
It("returns the absolute path when library exists", func() {
ctx := context.Background()
abs := AbsolutePath(ctx, ds, libId, path)
Expect(abs).To(Equal("/library/root/music/file.mp3"))
})
It("returns the original path if library not found", func() {
ctx := context.Background()
abs := AbsolutePath(ctx, ds, 999, path)
Expect(abs).To(Equal(path))
})
})
})

View File

@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/core/agents"
_ "github.com/navidrome/navidrome/core/agents/lastfm"
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
_ "github.com/navidrome/navidrome/core/agents/mcp"
_ "github.com/navidrome/navidrome/core/agents/spotify"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"

12
go.mod
View File

@@ -38,6 +38,7 @@ require (
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.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
@@ -53,6 +54,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/tetratelabs/wazero v1.9.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
go.uber.org/goleak v1.3.0
@@ -68,7 +70,9 @@ require (
require (
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
@@ -85,6 +89,7 @@ require (
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/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
@@ -96,9 +101,11 @@ require (
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/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
@@ -113,6 +120,11 @@ require (
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

27
go.sum
View File

@@ -8,12 +8,16 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
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=
@@ -104,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/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=
@@ -142,12 +149,16 @@ github.com/lestrrat-go/jwx/v2 v2.1.4 h1:uBCMmJX8oRZStmKuMMOFb0Yh9xmEMgNJLgjuKKt4
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.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=
@@ -167,6 +178,8 @@ github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib 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=
@@ -234,8 +247,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/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=

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

@@ -0,0 +1 @@

View File

@@ -77,7 +77,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"title": "order_title",
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, release_date, disc_number, track_number, order_artist_name, title",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"random": "random",
"created_at": "media_file.created_at",
"starred_at": "starred, starred_at",
@@ -242,7 +242,7 @@ func (r *mediaFileRepository) MarkMissingByFolder(missing bool, folderIDs ...str
// GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs)
// that were added/updated after the last scan started. The result is ordered by PID.
// It does not need to load bookmarks, annotations and participnts, as they are not used by the scanner.
// It does not need to load bookmarks, annotations and participants, as they are not used by the scanner.
func (r *mediaFileRepository) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
subQ := r.newSelect().Columns("pid").
Where(And{

View File

@@ -18,6 +18,9 @@
"size": "Mida del fitxer",
"updatedAt": "Actualitzat",
"bitRate": "Taxa de bits",
"bitDepth": "Bits",
"sampleRate": "Freqüencia de mostreig",
"channels": "Canals",
"discSubtitle": "Subtítol del disc",
"starred": "Preferit",
"comment": "Comentari",
@@ -25,8 +28,13 @@
"quality": "Qualitat",
"bpm": "tempo",
"playDate": "Darrer resproduït",
"channels": "Canals",
"createdAt": ""
"createdAt": "Creat el",
"grouping": "Agrupació",
"mood": "Sentiment",
"participants": "Participants",
"tags": "Etiquetes",
"mappedTags": "Etiquetes assignades",
"rawTags": "Etiquetes sense processar"
},
"actions": {
"addToQueue": "Reprodueix després",
@@ -46,6 +54,7 @@
"duration": "Durada",
"songCount": "Cançons",
"playCount": "Reproduccions",
"size": "Mida",
"name": "Nom",
"genre": "Gènere",
"compilation": "Compilació",
@@ -53,22 +62,28 @@
"updatedAt": "Actualitzat ",
"comment": "Comentari",
"rating": "Valoració",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": ""
"createdAt": "Creat el",
"size": "Mida",
"originalDate": "Original",
"releaseDate": "Publicat",
"releases": "LLançament |||| Llançaments",
"released": "Publicat",
"recordLabel": "Discogràfica",
"catalogNum": "Número de catàleg",
"releaseType": "Tipus de publicació",
"grouping": "Agrupació",
"media": "Mitjà",
"mood": "Sentiment"
},
"actions": {
"playAll": "Reprodueix",
"playNext": "Reprodueix la següent",
"addToQueue": "Reprodueix després",
"share": "Compartir",
"shuffle": "Aleatori",
"addToPlaylist": "Afegeix a la llista",
"download": "Descarrega",
"info": "Obtén informació",
"share": ""
"info": "Obtén informació"
},
"lists": {
"all": "Tot",
@@ -85,11 +100,27 @@
"fields": {
"name": "Nom",
"albumCount": "Nombre d'àlbums",
"songCount": "Compte de cançons",
"songCount": "Nombre de cançons",
"size": "Mida",
"playCount": "Reproduccions",
"rating": "Valoració",
"genre": "Gènere",
"size": ""
"role": "Rol"
},
"roles": {
"albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
"artist": "Artista |||| Artistes",
"composer": "Compositor |||| Compositors",
"conductor": "Conductor |||| Conductors",
"lyricist": "Lletrista |||| Lletristes",
"arranger": "Arranjador |||| Arranjadors",
"producer": "Productor |||| Productors",
"director": "Director |||| Directors",
"engineer": "Enginyer |||| Enginyers",
"mixer": "Mesclador |||| Mescladors",
"remixer": "Remesclador |||| Remescladors",
"djmixer": "DJ Mesclador |||| DJ Mescladors",
"performer": "Intèrpret |||| Intèrprets"
}
},
"user": {
@@ -98,6 +129,7 @@
"userName": "Nom d'usuari",
"isAdmin": "És admin",
"lastLoginAt": "Última connexió",
"lastAccessAt": "Últim Accés",
"updatedAt": "Actualitzat",
"name": "Nom",
"password": "Contrasenya",
@@ -169,36 +201,53 @@
}
},
"radio": {
"name": "",
"name": "Ràdio |||| Ràdios",
"fields": {
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
"name": "Nom",
"streamUrl": "URL del flux",
"homePageUrl": "URL principal",
"updatedAt": "Actualitzat",
"createdAt": "Creat"
},
"actions": {
"playNow": ""
"playNow": "Reprodueix"
}
},
"share": {
"name": "",
"name": "Compartir |||| Compartits",
"fields": {
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
}
},
"username": "Compartit per",
"url": "URL",
"description": "Descripció",
"downloadable": "Permet descarregar?",
"contents": "Continguts",
"expiresAt": "Caduca",
"lastVisitedAt": "Última Visita",
"visitCount": "Visites",
"format": "Format",
"maxBitRate": "Taxa de bits màx.",
"updatedAt": "Actualitzat",
"createdAt": "Creat"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Fitxer faltant |||| Fitxers Faltants",
"empty": "No falten fitxers",
"fields": {
"path": "Directori",
"size": "Mida",
"updatedAt": "Desaparegut"
},
"actions": {
"remove": "Eliminar"
},
"notifications": {
"removed": "Fitxers faltants eliminats"
}
}
},
"ra": {
"auth": {
"welcome1": "Gràcies d'haver instal·lat Navidrome!",
@@ -211,28 +260,30 @@
"password": "Contrasenya",
"sign_in": "Inicia sessió",
"sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar",
"logout": "Sortida"
"logout": "Sortida",
"insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols"
},
"validation": {
"invalidChars": "Si us plau, useu solament lletres i nombres",
"invalidChars": "Si us plau, useu només lletres i nombres",
"passwordDoesNotMatch": "Les contrasenyes no coincideixen",
"required": "Obligatori",
"minLength": "Ha de tenir, si més no, %{min} caràcters",
"maxLength": "Ha de tenir %{max} caràcter o menys",
"minValue": "Ha de ser si més no %{min}",
"maxLength": "Ha de tenir %{max} caràcters o menys",
"minValue": "Ha de ser com a mínim %{min}",
"maxValue": "Ha de ser %{max} o menys",
"number": "Ha de ser un nombre",
"email": "Ha de ser un correu vàlid",
"oneOf": "Ha de ser un de: %{options}",
"regex": "Ha de tenir el format (regexp): %{pattern}",
"unique": "Ha de ser únic",
"url": ""
"url": "Ha de ser una URL vàlida"
},
"action": {
"add_filter": "Afegeix un filtre",
"add": "Afegeix",
"back": "Enrere",
"bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Cancel·la",
"clear_input_value": "Neteja el valor",
"clone": "Clona",
@@ -256,9 +307,8 @@
"close_menu": "Tanca el menú",
"unselect": "Anul·la la selecció",
"skip": "Omet",
"bulk_actions_mobile": "",
"share": "",
"download": ""
"share": "Compartir",
"download": "Descarregar"
},
"boolean": {
"true": "Sí",
@@ -334,7 +384,7 @@
"i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat",
"canceled": "Acció cancel·lada",
"logged_out": "La sessió ha acabat, si us plau reconnecteu",
"new_version": "Hi ha una versió nova disponible! Si us plau refresqueu aquesta finestra."
"new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Columnes a mostrar",
@@ -351,29 +401,31 @@
"noPlaylistsAvailable": "No n'hi ha cap disponible",
"delete_user_title": "Esborra usuari '%{nom}'",
"delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)",
"remove_missing_title": "Eliminar fitxers faltants",
"remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.",
"notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador",
"notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https",
"lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment",
"lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm",
"lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment",
"lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm",
"listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}",
"listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat",
"listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz",
"openIn": {
"lastfm": "Obri en Last.fm",
"musicbrainz": "Obri en MusicBrainz"
},
"lastfmLink": "Llegeix més...",
"listenBrainzLinkSuccess": "Ha reexit la vinculació amb ListenBrainz i se n'ha activat el seguiment com a usuari: %{user}",
"listenBrainzLinkFailure": "No ha estat possible vincular-se a ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "Desvinculat de ListenBrainz i desactivat el seguiment",
"listenBrainzUnlinkFailure": "No s'ha pogut desvincular de ListenBrainz",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": ""
"shareOriginalFormat": "Compartir en format original",
"shareDialogTitle": "Compartir %{resource} '%{name}'",
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
"shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter",
"shareSuccess": "URL copiada al porta-retalls: %{url}",
"shareFailure": "Error copiant URL %{url} al porta-retalls",
"downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Descarregar en format original"
},
"menu": {
"library": "Discoteca",
@@ -387,14 +439,15 @@
"language": "Llengua",
"defaultView": "Vista per defecte",
"desktop_notifications": "Notificacions d'escriptori",
"lastfmNotConfigured": "No s'ha configurat l'API de Last.fm",
"lastfmScrobbling": "Activa el seguiment de Last.fm",
"listenBrainzScrobbling": "Activa el seguiment de ListenBrainz",
"replaygain": "",
"preAmp": "",
"replaygain": "Mode ReplayGain",
"preAmp": "PreAmp de ReplayGain (dB)",
"gain": {
"none": "",
"album": "",
"track": ""
"none": "Cap",
"album": "Guany de l'àlbum",
"track": "Guany de la pista"
}
}
},
@@ -432,7 +485,12 @@
"links": {
"homepage": "Inici",
"source": "Codi font",
"featureRequests": "Sol·licitud de funcionalitats"
"featureRequests": "Sol·licitud de funcionalitats",
"lastInsightsCollection": "Última recolecció d'informació",
"insights": {
"disabled": "Desactivada",
"waiting": "Esperant"
}
}
},
"activity": {
@@ -454,7 +512,7 @@
"vol_up": "Apuja el volum",
"vol_down": "Abaixa el volum",
"toggle_love": "Afegeix la pista a favorits",
"current_song": ""
"current_song": "Anar a la cançó actual"
}
}
}
}

View File

@@ -1,8 +1,8 @@
{
"languageName": "Engelsk",
"languageName": "Norsk",
"resources": {
"song": {
"name": "Låt |||| Låter",
"name": "Sang |||| Sanger",
"fields": {
"albumArtist": "Album Artist",
"duration": "Tid",
@@ -11,164 +11,165 @@
"title": "Tittel",
"artist": "Artist",
"album": "Album",
"path": "Filbane",
"path": "Filsti",
"genre": "Sjanger",
"compilation": "Samling",
"compilation": "Samlingg",
"year": "År",
"size": "Filstørrelse",
"updatedAt": "Oppdatert kl",
"bitRate": "Bithastighet",
"discSubtitle": "Diskundertekst",
"updatedAt": "Oppdatert",
"bitRate": "Bit rate",
"bitDepth": "Bit depth",
"channels": "Kanaler",
"discSubtitle": "Disk Undertittel",
"starred": "Favoritt",
"comment": "Kommentar",
"rating": "Vurdering",
"rating": "Rangering",
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Sist spilt",
"channels": "Kanaler",
"createdAt": "",
"grouping": "",
"mood": "",
"participants": "",
"tags": "",
"mappedTags": "",
"rawTags": "",
"bitDepth": ""
"playDate": "Sist Avspilt",
"createdAt": "Lagt til",
"grouping": "Gruppering",
"mood": "Stemning",
"participants": "Ytterlige deltakere",
"tags": "Ytterlige Tags",
"mappedTags": "Kartlagte tags",
"rawTags": "Rå tags"
},
"actions": {
"addToQueue": "Spill Senere",
"playNow": "Leke nå",
"addToQueue": "Avspill senere",
"playNow": "Avspill nå",
"addToPlaylist": "Legg til i spilleliste",
"shuffleAll": "Bland alle",
"download": "nedlasting",
"playNext": "Spill Neste",
"info": "Få informasjon"
"shuffleAll": "Shuffle Alle",
"download": "Last ned",
"playNext": "Avspill neste",
"info": "Få Info"
}
},
"album": {
"name": "Album",
"name": "Album |||| Album",
"fields": {
"albumArtist": "Album Artist",
"artist": "Artist",
"duration": "Tid",
"songCount": "Sanger",
"playCount": "Avspillinger",
"size": "Størrelse",
"name": "Navn",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
"updatedAt": "Oppdatert kl",
"date": "Inspillingsdato",
"originalDate": "Original",
"releaseDate": "Utgitt",
"releases": "Utgivelse |||| Utgivelser",
"released": "Utgitt",
"updatedAt": "Oppdatert",
"comment": "Kommentar",
"rating": "Vurdering",
"createdAt": "",
"size": "",
"originalDate": "",
"releaseDate": "",
"releases": "",
"released": "",
"recordLabel": "",
"catalogNum": "",
"releaseType": "",
"grouping": "",
"media": "",
"mood": ""
"rating": "Rangering",
"createdAt": "Lagt Til",
"recordLabel": "Plateselskap",
"catalogNum": "Katalognummer",
"releaseType": "Type",
"grouping": "Gruppering",
"media": "Media",
"mood": "Stemning"
},
"actions": {
"playAll": "Spill",
"playNext": "Spill neste",
"addToQueue": "Spille senere",
"shuffle": "Bland",
"playAll": "Avspill",
"playNext": "Avspill Neste",
"addToQueue": "Avspill Senere",
"share": "Del",
"shuffle": "Shuffle",
"addToPlaylist": "Legg til i spilleliste",
"download": "nedlasting",
"info": "Få informasjon",
"share": ""
"download": "Last ned",
"info": "Få Info"
},
"lists": {
"all": "Alle",
"random": "Tilfeldig",
"recentlyAdded": "Nylig lagt til",
"recentlyPlayed": "Nylig spilt",
"mostPlayed": "Mest spilte",
"recentlyPlayed": "Nylig Avspilt",
"mostPlayed": "Mest Avspilt",
"starred": "Favoritter",
"topRated": "Topp rangert"
"topRated": "Top Rangert"
}
},
"artist": {
"name": "Artist |||| Artister",
"fields": {
"name": "Navn",
"albumCount": "Antall album",
"songCount": "Antall sanger",
"playCount": "Spiller",
"rating": "Vurdering",
"albumCount": "Album Antall",
"songCount": "Song Antall",
"size": "Størrelse",
"playCount": "Avspillinger",
"rating": "Rangering",
"genre": "Sjanger",
"size": "",
"role": ""
"role": "Rolle"
},
"roles": {
"albumartist": "",
"artist": "",
"composer": "",
"conductor": "",
"lyricist": "",
"arranger": "",
"producer": "",
"director": "",
"engineer": "",
"mixer": "",
"remixer": "",
"djmixer": "",
"performer": ""
"albumartist": "Album Artist |||| Album Artister",
"artist": "Artist |||| Artister",
"composer": "Composer |||| Composers",
"conductor": "Conductor |||| Conductors",
"lyricist": "Lyriker |||| Lyriker",
"arranger": "Arranger |||| Arrangers",
"producer": "Produsent |||| Produsenter",
"director": "Director |||| Directors",
"engineer": "Engineer |||| Engineers",
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
"performer": "Performer |||| Performers"
}
},
"user": {
"name": "Bruker |||| Brukere",
"fields": {
"userName": "Brukernavn",
"isAdmin": "er admin",
"lastLoginAt": "Siste pålogging kl",
"updatedAt": "Oppdatert kl",
"isAdmin": "Admin",
"lastLoginAt": "Sist Pålogging",
"lastAccessAt": "Sist Tilgang",
"updatedAt": "Oppdatert",
"name": "Navn",
"password": "Passord",
"createdAt": "Opprettet kl",
"changePassword": "Bytte Passord",
"createdAt": "Opprettet",
"changePassword": "Bytt Passord?",
"currentPassword": "Nåværende Passord",
"newPassword": "Nytt Passord",
"token": "Token",
"lastAccessAt": ""
"token": "Token"
},
"helperTexts": {
"name": "Endringer i navnet ditt vil kun gjenspeiles ved neste pålogging"
"name": "Navnendringer vil ikke være synlig før neste pålogging"
},
"notifications": {
"created": "Bruker opprettet",
"updated": "Bruker oppdatert",
"deleted": "Bruker fjernet"
"deleted": "Bruker slettet"
},
"message": {
"listenBrainzToken": "Skriv inn ListenBrainz-brukertokenet ditt.",
"clickHereForToken": "Klikk her for å få tokenet ditt"
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
"clickHereForToken": "Klikk her for å hente din token"
}
},
"player": {
"name": "Avspiller |||| Avspillere",
"name": "Musikkavspiller |||| Musikkavspillere",
"fields": {
"name": "Navn",
"transcodingId": "Omkoding",
"maxBitRate": "Maks. Bithastighet",
"transcodingId": "Transkoding",
"maxBitRate": "Maks. Bit Rate",
"client": "Klient",
"userName": "Brukernavn",
"lastSeen": "Sist sett kl",
"reportRealPath": "Rapporter ekte sti",
"lastSeen": "Sist sett",
"reportRealPath": "Rapporter ekte filsti",
"scrobbleEnabled": "Send Scrobbles til eksterne tjenester"
}
},
"transcoding": {
"name": "Omkoding |||| Omkodinger",
"name": "Transkoding |||| Transkodinger",
"fields": {
"name": "Navn",
"targetFormat": "Målformat",
"defaultBitRate": "Standard bithastighet",
"targetFormat": "Mål Format",
"defaultBitRate": "Default Bit Rate",
"command": "Kommando"
}
},
@@ -176,135 +177,137 @@
"name": "Spilleliste |||| Spillelister",
"fields": {
"name": "Navn",
"duration": "Varighet",
"ownerName": "Eieren",
"duration": "Lengde",
"ownerName": "Eier",
"public": "Offentlig",
"updatedAt": "Oppdatert kl",
"createdAt": "Opprettet kl",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet",
"songCount": "Sanger",
"comment": "Kommentar",
"sync": "Autoimport",
"path": "Import fra"
"sync": "Auto-importer",
"path": "Importer fra"
},
"actions": {
"selectPlaylist": "Velg en spilleliste:",
"addNewPlaylist": "Opprett \"%{name}\"",
"export": "Eksport",
"makePublic": "Gjør offentlig",
"makePrivate": "Gjør privat"
"export": "Eksporter",
"makePublic": "Gjør Offentlig",
"makePrivate": "Gjør Privat"
},
"message": {
"duplicate_song": "Legg til dupliserte sanger",
"song_exist": "Det legges til duplikater i spillelisten. Vil du legge til duplikatene eller hoppe over dem?"
"duplicate_song": "Legg til Duplikater",
"song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
}
},
"radio": {
"name": "",
"name": "Radio |||| Radio",
"fields": {
"name": "",
"streamUrl": "",
"homePageUrl": "",
"updatedAt": "",
"createdAt": ""
"name": "Navn",
"streamUrl": "Stream URL",
"homePageUrl": "Hjemmeside URL",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet"
},
"actions": {
"playNow": ""
"playNow": "Avspill"
}
},
"share": {
"name": "",
"name": "Del |||| Delinger",
"fields": {
"username": "",
"url": "",
"description": "",
"contents": "",
"expiresAt": "",
"lastVisitedAt": "",
"visitCount": "",
"format": "",
"maxBitRate": "",
"updatedAt": "",
"createdAt": "",
"downloadable": ""
}
"username": "Delt Av",
"url": "URL",
"description": "Beskrivelse",
"downloadable": "Tillat Nedlastinger?",
"contents": "Innhold",
"expiresAt": "Utløper",
"lastVisitedAt": "Sist Besøkt",
"visitCount": "Visninger",
"format": "Format",
"maxBitRate": "Maks. Bit Rate",
"updatedAt": "Oppdatert",
"createdAt": "Opprettet"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "",
"name": "Manglende Fil|||| Manglende Filer",
"empty": "Ingen Manglende Filer",
"fields": {
"path": "",
"size": "",
"updatedAt": ""
"path": "Filsti",
"size": "Størrelse",
"updatedAt": "Ble borte"
},
"actions": {
"remove": ""
"remove": "Fjern"
},
"notifications": {
"removed": ""
},
"empty": ""
"removed": "Manglende fil(er) fjernet"
}
}
},
"ra": {
"auth": {
"welcome1": "Takk for at du installerte Navidrome!",
"welcome2": "Opprett en admin -bruker for å starte",
"welcome2": "La oss begynne med å lage en admin bruker.",
"confirmPassword": "Bekreft Passord",
"buttonCreateAdmin": "Opprett Admin",
"auth_check_error": "Vennligst Logg inn for å fortsette",
"auth_check_error": "Logg inn for å fortsette",
"user_menu": "Profil",
"username": "Brukernavn",
"password": "Passord",
"sign_in": "Logg inn",
"sign_in_error": "Autentisering mislyktes. Prøv på nytt",
"sign_in_error": "Autentiseringsfeil, vennligst prøv igjen",
"logout": "Logg ut",
"insightsCollectionNote": ""
"insightsCollectionNote": "Navidrome innhenter anonymisert forbruksdata\nfor å hjelpe og forbedre prosjektet.\nTrykk [her] for å lære mer og for å melde deg av hvis ønskelig."
},
"validation": {
"invalidChars": "Bruk bare bokstaver og tall",
"passwordDoesNotMatch": "Passordet er ikke like",
"required": "Obligatorisk",
"minLength": "Må være minst %{min} tegn",
"maxLength": "Må være %{max} tegn eller færre",
"invalidChars": "Det er kun bokstaver og tall som støttes",
"passwordDoesNotMatch": "Passord samstemmer ikke",
"required": "Kreves",
"minLength": "Må være minst %{min} karakterer.",
"maxLength": "Må være %{max} karakterer eller mindre",
"minValue": "Må være minst %{min}",
"maxValue": "Må være %{max} eller mindre",
"number": "Må være et tall",
"email": "Må være en gyldig e-post",
"email": "Må være en gyldig epost",
"oneOf": "Må være en av: %{options}",
"regex": "Må samsvare med et spesifikt format (regexp): %{pattern}",
"unique": "Må være unik",
"url": ""
"regex": "Må samstemme med et spesifikt format (regexp): %{pattern}",
"unique": "Må være unikt",
"url": "Må være en gyldig URL"
},
"action": {
"add_filter": "Legg til filter",
"add": "Legge til",
"back": "Gå tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer er valgt",
"add": "Legg Til",
"back": "Tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Avbryt",
"clear_input_value": "Klar verdi",
"clear_input_value": "Nullstill verdi",
"clone": "Klone",
"confirm": "Bekrefte",
"create": "Skape",
"confirm": "Bekreft",
"create": "Opprett",
"delete": "Slett",
"edit": "Redigere",
"export": "Eksport",
"edit": "Rediger",
"export": "Eksporter",
"list": "Liste",
"refresh": "oppdater",
"refresh": "Oppdater",
"remove_filter": "Fjern dette filteret",
"remove": "Fjerne",
"remove": "Fjern",
"save": "Lagre",
"search": "Søk",
"show": "Vis",
"sort": "Sortere",
"sort": "Sorter",
"undo": "Angre",
"expand": "Utvide",
"expand": "Utvid",
"close": "Lukk",
"open_menu": "Åpne menyen",
"close_menu": "Lukk menyen",
"unselect": "Fjern valget",
"open_menu": "Åpne meny",
"close_menu": "Lukk meny",
"unselect": "Avvelg",
"skip": "Hopp over",
"bulk_actions_mobile": "",
"share": "",
"download": ""
"share": "Del",
"download": "Last Ned"
},
"boolean": {
"true": "Ja",
@@ -312,29 +315,29 @@
},
"page": {
"create": "Opprett %{name}",
"dashboard": "Dashbord",
"dashboard": "Dashboard",
"edit": "%{name} #%{id}",
"error": "Noe gikk galt",
"list": "%{Navn}",
"list": "%{name}",
"loading": "Laster",
"not_found": "Ikke funnet",
"not_found": "Ikke Funnet",
"show": "%{name} #%{id}",
"empty": "Ingen %{name} en.",
"invite": "Vil du legge til en?"
"empty": "Ingen %{name} enda.",
"invite": "Ønsker du å legge til en?"
},
"input": {
"file": {
"upload_several": "Slipp noen filer for å laste opp, eller klikk for å velge en.",
"upload_single": "Slipp en fil for å laste opp, eller klikk for å velge den."
"upload_several": "Dra filer hit for å laste opp, eller klikk for å velge en.",
"upload_single": "Dra en fil hit for å laste opp, eller klikk for å velge den."
},
"image": {
"upload_several": "Slipp noen bilder for å laste opp, eller klikk for å velge ett.",
"upload_single": "Slipp et bilde for å laste opp, eller klikk for å velge det."
"upload_several": "Dra bilder hit for å laste opp, eller klikk for å velge en.",
"upload_single": "Dra et bilde hit for å laste opp, eller klikk for å velge den."
},
"references": {
"all_missing": "Kan ikke finne referansedata.",
"many_missing": "Minst én av de tilknyttede referansene ser ikke ut til å være tilgjengelig lenger.",
"single_missing": "Tilknyttet referanse ser ikke lenger ut til å være tilgjengelig."
"all_missing": "Finner ikke referansedata.",
"many_missing": "Minst en av de tilhørende referansene ser ikke lenger ut til å være tilgjengelig.",
"single_missing": "Tilhørende referanse ser ikke lenger ut til å være tilgjengelig."
},
"password": {
"toggle_visible": "Skjul passord",
@@ -346,86 +349,86 @@
"are_you_sure": "Er du sikker?",
"bulk_delete_content": "Er du sikker på at du vil slette denne %{name}? |||| Er du sikker på at du vil slette disse %{smart_count} elementene?",
"bulk_delete_title": "Slett %{name} |||| Slett %{smart_count} %{name}",
"delete_content": "Er du sikker på at du vil slette dette elementet?",
"delete_content": "Er du sikker på at du ønsker å slette dette elementet?",
"delete_title": "Slett %{name} #%{id}",
"details": "Detaljer",
"error": "Det oppstod en klientfeil og forespørselen din kunne ikke fullføres.",
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil",
"loading": "Siden lastes, bare et øyeblikk",
"error": "En klient feil har oppstått og din forespørsel lot seg ikke gjennomføre.",
"invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil.",
"loading": "Siden laster, vennligst vent.",
"no": "Nei",
"not_found": "Enten skrev du inn feil URL, eller så fulgte du en dårlig lenke.",
"not_found": "Enten skrev du feil URL, eller så har du fulgt en dårlig link.",
"yes": "Ja",
"unsaved_changes": "Noen av endringene dine ble ikke lagret. Er du sikker på at du vil ignorere dem?"
"unsaved_changes": "Noen av dine endringer ble ikke lagret. Er du sikker på at du ønsker å ignorere de?"
},
"navigation": {
"no_results": "Ingen resultater",
"no_more_results": "Sidetallet %{page} er utenfor grensene. Prøv forrige side.",
"page_out_of_boundaries": "Sidetall %{page} utenfor grensene",
"page_out_from_end": "Kan ikke etter siste side",
"page_out_from_begin": "Kan ikke før side 1",
"no_more_results": "Sidenummeret %{page} er utenfor grensene. Prøv forrige side.",
"page_out_of_boundaries": "Sidenummer %{page} er utenfor grensene",
"page_out_from_end": "Kan ikke være etter siste side",
"page_out_from_begin": "Kan ikke være før side 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}",
"page_rows_per_page": "Elementer per side:",
"next": "Neste",
"prev": "Forrige",
"skip_nav": "Hopp til innholdet"
"skip_nav": "Hopp til innhold"
},
"notification": {
"updated": "Element oppdatert |||| %{smart_count} elementer er oppdatert",
"updated": "Element oppdatert |||| %{smart_count} elementer oppdatert",
"created": "Element opprettet",
"deleted": "Element slettet |||| %{smart_count} elementer slettet",
"bad_item": "Feil element",
"item_doesnt_exist": "Elementet eksisterer ikke",
"http_error": "Serverkommunikasjonsfeil",
"data_provider_error": "dataleverandørfeil. Sjekk konsollen for detaljer.",
"i18n_error": "Kan ikke laste oversettelsene for det angitte språket",
"canceled": "Handlingen avbrutt",
"logged_out": "Økten din er avsluttet. Koble til på nytt.",
"new_version": "Ny versjon tilgjengelig! Trykk Oppdater "
"item_doesnt_exist": "Element eksisterer ikke",
"http_error": "Kommunikasjonsfeil mot server",
"data_provider_error": "dataProvider feil. Sjekk konsollet for feil.",
"i18n_error": "Klarte ikke laste oversettelser for valgt språk.",
"canceled": "Handling avbrutt",
"logged_out": "Din sesjon er avsluttet, vennligst koble til på nytt.",
"new_version": "Ny versjon tilgjengelig! Vennligst last siden på nytt."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Kolonner som skal vises",
"layout": "Oppsett",
"grid": "Nett",
"table": "Bord"
"columnsToDisplay": "Vis følgende kolonner",
"layout": "Layout",
"grid": "Rutenett",
"table": "Tabell"
}
},
"message": {
"note": "Info",
"transcodingDisabled": "Endring av transkodingskonfigurasjonen gjennom webgrensesnittet er deaktivert av sikkerhetsgrunner. Hvis du ønsker å endre (redigere eller legge til) transkodingsalternativer, start serveren på nytt med %{config}-konfigurasjonsalternativet.",
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, noe som gjør det mulig å kjøre systemkommandoer fra transkodingsinnstillingene ved å bruke nettgrensesnittet. Vi anbefaler å deaktivere den av sikkerhetsgrunner og bare aktivere den når du konfigurerer alternativer for omkoding.",
"note": "NOTAT",
"transcodingDisabled": "Endringer på transkodingkonfigurasjon fra web grensesnittet er deaktivert grunnet sikkerhet. Hvis du ønsker å endre eller legge til transkodingsmuligheter, restart serveren med %{config} konfigurasjonsalternativ.",
"transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, som gjør det mulig å kjøre systemkommandoer fra transkodingsinstillinger i web grensesnittet. Vi anbefaler å deaktivere denne muligheten av sikkerhetsårsaker og heller kun ha det aktivert under konfigurasjon av transkodingsmuligheter.",
"songsAddedToPlaylist": "Lagt til 1 sang i spillelisten |||| Lagt til %{smart_count} sanger i spillelisten",
"noPlaylistsAvailable": "Ingen tilgjengelig",
"delete_user_title": "Slett bruker «%{name}»",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og alle dataene deres (inkludert spillelister og preferanser)?",
"notifications_blocked": "Du har blokkert varsler for dette nettstedet i nettleserens innstillinger",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsvarsler, eller du har ikke tilgang til Navidrome over https",
"lastfmLinkSuccess": "Last.fm er vellykket koblet og scrobbling aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke kobles til",
"lastfmUnlinkSuccess": "Last.fm koblet fra og scrobbling deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke kobles fra",
"delete_user_title": "Slett bruker '%{name}'",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
"remove_missing_title": "Fjern manglende filer",
"remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
"listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
},
"lastfmLink": "Les mer...",
"listenBrainzLinkSuccess": "ListenBrainz er vellykket koblet og scrobbling aktivert som bruker: %{user}",
"listenBrainzLinkFailure": "ListenBrainz kunne ikke kobles: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz koblet fra og scrobbling deaktivert",
"listenBrainzUnlinkFailure": "ListenBrainz kunne ikke fjernes",
"downloadOriginalFormat": "",
"shareOriginalFormat": "",
"shareDialogTitle": "",
"shareBatchDialogTitle": "",
"shareSuccess": "",
"shareFailure": "",
"downloadDialogTitle": "",
"shareCopyToClipboard": "",
"remove_missing_title": "",
"remove_missing_content": ""
"lastfmLink": "Les Mer...",
"shareOriginalFormat": "Del i originalformat",
"shareDialogTitle": "Del %{resource} '%{name}'",
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
"shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Last ned i originalformat"
},
"menu": {
"library": "Bibliotek",
"settings": "Innstillinger",
"settings": "Instillinger",
"version": "Versjon",
"theme": "Tema",
"personal": {
@@ -434,81 +437,81 @@
"theme": "Tema",
"language": "Språk",
"defaultView": "Standardvisning",
"desktop_notifications": "Skrivebordsvarsler",
"desktop_notifications": "Skrivebordsnotifikasjoner",
"lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
"lastfmScrobbling": "Scrobble til Last.fm",
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
"replaygain": "",
"preAmp": "",
"replaygain": "ReplayGain Mode",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "",
"album": "",
"track": ""
},
"lastfmNotConfigured": ""
"none": "Deaktivert",
"album": "Bruk Album Gain",
"track": "Bruk Track Gain"
}
}
},
"albumList": "Album",
"about": "Om",
"playlists": "Spilleliste",
"sharedPlaylists": "Delte spillelister"
"playlists": "Spillelister",
"sharedPlaylists": "Delte Spillelister",
"about": "Om"
},
"player": {
"playListsText": "Spillekø",
"playListsText": "Spill Av Kø",
"openText": "Åpne",
"closeText": "Lukk",
"notContentText": "Ingen musikk",
"clickToPlayText": "Klikk for å spille",
"clickToPauseText": "Klikk for å sette på pause",
"clickToPlayText": "Klikk for å avspille",
"clickToPauseText": "Klikk for å pause",
"nextTrackText": "Neste spor",
"previousTrackText": "Forrige spor",
"reloadText": "Last inn på nytt",
"reloadText": "Last på nytt",
"volumeText": "Volum",
"toggleLyricText": "Veksle mellom tekster",
"toggleLyricText": "Slå på/av sangtekster",
"toggleMiniModeText": "Minimer",
"destroyText": "Ødelegge",
"downloadText": "nedlasting",
"destroyText": "Ødelegg",
"downloadText": "Last Ned",
"removeAudioListsText": "Slett lydlister",
"clickToDeleteText": "Klikk for å slette %{name}",
"emptyLyricText": "Ingen sangtekster",
"playModeText": {
"order": "I rekkefølge",
"orderLoop": "Gjenta",
"singleLoop": "Gjenta engang",
"shufflePlay": "Tilfeldig rekkefølge"
"orderLoop": "Repeat",
"singleLoop": "Repeat En",
"shufflePlay": "Shuffle"
}
},
"about": {
"links": {
"homepage": "Hjemmeside",
"source": "Kildekode",
"featureRequests": "Funksjonsforespørsler",
"lastInsightsCollection": "",
"featureRequests": "Funksjonsforespørseler",
"lastInsightsCollection": "Siste Innsamling av anonymisert forbruksdata",
"insights": {
"disabled": "",
"waiting": ""
"disabled": "Deaktivert",
"waiting": "Venter"
}
}
},
"activity": {
"title": "Aktivitet",
"totalScanned": "Totalt skannede mapper",
"quickScan": "Rask skanning",
"fullScan": "Full skanning",
"serverUptime": "Serveroppetid",
"totalScanned": "Antall mapper skannet",
"quickScan": "Hurtigskann",
"fullScan": "Full Skann",
"serverUptime": "Server Oppetid",
"serverDown": "OFFLINE"
},
"help": {
"title": "Navidrome hurtigtaster",
"title": "Navidrome Hurtigtaster",
"hotkeys": {
"show_help": "Vis denne hjelpen",
"toggle_menu": "Bytt menysidelinje",
"toggle_play": "Spill / Pause",
"prev_song": "Forrige sang",
"next_song": "Neste sang",
"vol_up": "Volum opp",
"vol_down": "Volum ned",
"toggle_love": "Legg til dette sporet i favoritter",
"current_song": ""
"show_help": "Vis Hjelp",
"toggle_menu": "Åpne/Lukke Sidepanel",
"toggle_play": "Avspill / Pause",
"prev_song": "Forrige Sang",
"next_song": "Neste Sang",
"current_song": "Gå til Nåværende Sang",
"vol_up": "Volum Opp",
"vol_down": "Volum Ned",
"toggle_love": "Legg til spor i favoritter"
}
}
}
}

View File

@@ -33,7 +33,8 @@
"tags": "Дополнительные теги",
"mappedTags": "Сопоставленные теги",
"rawTags": "Исходные теги",
"bitDepth": "Битовая глубина"
"bitDepth": "Битовая глубина",
"sampleRate": "Частота дискретизации (Гц)"
},
"actions": {
"addToQueue": "В очередь",
@@ -72,7 +73,7 @@
"grouping": "Группирование",
"media": "Медиа",
"mood": "Настроение",
"date": ""
"date": "Дата записи"
},
"actions": {
"playAll": "Играть",

View File

@@ -1,465 +1,517 @@
{
"languageName": "српски",
"resources": {
"song": {
"name": "Песма |||| Песме",
"fields": {
"albumArtist": "Уметник албума",
"duration": "Трајање",
"trackNumber": "#",
"playCount": "Пуштано",
"title": "Наслов",
"artist": "Уметник",
"album": "Албум",
"path": "Путања фајла",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"size": "Величина фајла",
"updatedAt": "Ажурирано",
"bitRate": "Битски проток",
"channels": "Канала",
"discSubtitle": "Поднаслов диска",
"starred": "Омиљено",
"comment": "Коментар",
"rating": "Рејтинг",
"quality": "Квалитет",
"bpm": "BPM",
"playDate": "Последње пуштано",
"createdAt": "Датум додавања"
},
"actions": {
"addToQueue": "Пусти касније",
"playNow": "Пусти одмах",
"addToPlaylist": "Додај у плејлисту",
"shuffleAll": "Измешај све",
"download": "Преузми",
"playNext": "Пусти наредно",
"info": "Прикажи инфо"
}
},
"album": {
"name": "Албум |||| Албуми",
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
"duration": "Трајање",
"songCount": "Песме",
"playCount": "Пуштано",
"size": "Величина",
"name": "Назив",
"genre": "Жанр",
"compilation": "Компилација",
"year": "Година",
"originalDate": "Оригинално",
"releaseDate": "Објављено",
"releases": "Издање|||| Издања",
"released": "Објављено",
"updatedAt": "Ажурирано",
"comment": "Коментар",
"rating": "Рејтинг",
"createdAt": "Датум додавања"
},
"actions": {
"playAll": "Пусти",
"playNext": "Пусти наредно",
"addToQueue": "Пусти касније",
"share": "Дели",
"shuffle": "Измешај",
"addToPlaylist": "Додај у плејлисту",
"download": "Преузми",
"info": "Прикажи инфо"
},
"lists": {
"all": "Све",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
"mostPlayed": "Најчешће пуштано",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
},
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
"name": "Име",
"albumCount": "Број албума",
"songCount": "Број песама",
"size": "Величина",
"playCount": "Пуштано",
"rating": "Рејтинг",
"genre": "Жанр"
}
},
"user": {
"name": "Корисник |||| Корисници",
"fields": {
"userName": "Корисничко име",
"isAdmin": "Да ли је Админ",
"lastLoginAt": "Последња пријава",
"lastAccessAt": "Последњи приступ",
"updatedAt": "Ажурирано",
"name": "Име",
"password": "Лозинка",
"createdAt": "Креирана",
"changePassword": "Измени лозинку?",
"currentPassword": "Текућа лозинка",
"newPassword": "Нова лозинка",
"token": "Жетон"
},
"helperTexts": {
"name": "Измене вашег имена ће постати видљиве након следеће пријаве"
},
"notifications": {
"created": "Корисник креиран",
"updated": "Корисник ажуриран",
"deleted": "Корисник обрисан"
},
"message": {
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.",
"clickHereForToken": "Кликните овде да преузмете свој жетон"
}
},
"player": {
"name": "Плејер |||| Плејери",
"fields": {
"name": "Назив",
"transcodingId": "Транскодирање",
"maxBitRate": "Макс. битски проток",
"client": "Клијент",
"userName": "Корисничко име",
"lastSeen": "последњи пут виђен",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе"
}
},
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
"name": "Назив",
"targetFormat": "Циљни формат",
"defaultBitRate": "Подразумевани битски проток",
"command": "Команда"
}
},
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
"name": "Назив",
"duration": "Трајање",
"ownerName": "Власник",
"public": "Јавна",
"updatedAt": "Ажурирана",
"createdAt": "Креирана",
"songCount": "Песме",
"comment": "Коментар",
"sync": "Ауто-увоз",
"path": "Увоз из"
},
"actions": {
"selectPlaylist": "Изабери плејлисту",
"addNewPlaylist": "Креирај „%{name}”",
"export": "Извоз",
"makePublic": "Учини јавном",
"makePrivate": "Учини приватном"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
}
},
"radio": {
"name": "Радио |||| Радији",
"fields": {
"name": "Назив",
"streamUrl": "URL тока",
"homePageUrl": "URL почетне странице",
"updatedAt": "Ажурирано",
"createdAt": "Креирано"
},
"actions": {
"playNow": "Пусти одмах"
}
},
"share": {
"name": "Дељење |||| Дељења",
"fields": {
"username": "Поделио",
"url": "URL",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
"contents": "Садржај",
"expiresAt": "Истиче",
"lastVisitedAt": "Последњи пут посећено",
"visitCount": "Број посета",
"format": "Формат",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
"createdAt": "Креирано"
},
"notifications": {
},
"actions": {
}
}
"languageName": "српски",
"resources": {
"song": {
"name": "Песма |||| Песме",
"fields": {
"album": "Албум",
"albumArtist": "Уметник албума",
"artist": "Уметник",
"bitDepth": "Битова",
"bitRate": "Битски проток",
"bpm": "BPM",
"channels": "Канала",
"comment": "Коментар",
"compilation": "Компилација",
"createdAt": "Датум додавања",
"discSubtitle": "Поднаслов диска",
"duration": "Трајање",
"genre": "Жанр",
"grouping": "Груписање",
"mappedTags": "Мапиране ознаке",
"mood": "Расположење",
"participants": "Додатни учесници",
"path": "Путања фајла",
"playCount": "Пуштано",
"playDate": "Последње пуштано",
"quality": "Квалитет",
"rating": "Рејтинг",
"rawTags": "Сирове ознаке",
"size": "Величина фајла",
"starred": "Омиљено",
"tags": "Додатне ознаке",
"title": "Наслов",
"trackNumber": "#",
"updatedAt": "Ажурирано",
"year": "Година"
},
"actions": {
"addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
"download": "Преузми",
"info": "Прикажи инфо",
"playNext": "Пусти наредно",
"playNow": "Пусти одмах",
"shuffleAll": "Измешај све"
}
},
"ra": {
"auth": {
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника",
"confirmPassword": "Потврдите лозинку",
"buttonCreateAdmin": "Креирај админа",
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"user_menu": "Профил",
"username": "Корисничко име",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
"logout": "Одјави се"
},
"validation": {
"invalidChars": "Молимо вас да користите само слова и цифре",
"passwordDoesNotMatch": "Лозинка се не подудара",
"required": "Неопходно",
"minLength": "Мора да буде барем %{min} карактера",
"maxLength": "Мора да буде %{max} карактера или мање",
"minValue": "Мора да буде барем %{min}",
"maxValue": "Мора да буде %{max} или мање",
"number": "Мора да буде број",
"email": "Мора да буде исправна и-мејл адреса",
"oneOf": "Мора да буде једно од: %{options}",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
"add_filter": "Додај филтер",
"add": "Додај",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
"edit": "Уреди",
"export": "Извези",
"list": "Листа",
"refresh": "Освежи",
"remove_filter": "Уклони овај филтер",
"remove": "Уклони",
"save": "Сачувај",
"search": "Тражи",
"show": "Прикажи",
"sort": "Сортирај",
"undo": "Поништи",
"expand": "Развиј",
"close": "Затвори",
"open_menu": "Отвори мени",
"close_menu": "Затвори мени",
"unselect": "Уклони избор",
"skip": "Прескочи",
"share": "Подели",
"download": "Преузми"
},
"boolean": {
"true": "Да",
"false": "Не"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
"error": "Нешто је пошло наопако",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
"show": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"invite": "Желите ли да се дода?"
},
"input": {
"file": {
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
},
"image": {
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
},
"password": {
"toggle_visible": "Сакриј лозинку",
"toggle_hidden": "Прикажи лозинку"
}
},
"message": {
"about": "О програму",
"are_you_sure": "Да ли сте сигурни?",
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
"delete_content": "Да ли заиста желите да обришете ову ставку?",
"delete_title": "Брисање %{name} #%{id}",
"details": "Детаљи",
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
"yes": "Да",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?"
},
"navigation": {
"no_results": "Није пронађен ниједан резултат",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
"page_out_from_end": "Не може да се иде након последње странице",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
"next": "Наредна",
"prev": "Претход",
"skip_nav": "Прескочи на садржај"
},
"notification": {
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано",
"created": "Елемент је креиран",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
"bad_item": "Неисправни елемент",
"item_doesnt_exist": "Елемент не постоји",
"http_error": "Грешка у комуникацији са сервером",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"canceled": "Акција је отказана",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор."
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
"layout": "Распоред",
"grid": "Мрежа",
"table": "Табела"
}
"album": {
"name": "Албум |||| Албуми",
"fields": {
"albumArtist": "Уметник албума",
"artist": "Уметник",
"catalogNum": "Каталошки број",
"comment": "Коментар",
"compilation": "Компилација",
"createdAt": "Датум додавања",
"date": "Датум снимања",
"duration": "Трајање",
"genre": "Жанр",
"grouping": "Груписање",
"media": "Медијум",
"mood": "Расположење",
"name": "Назив",
"originalDate": "Оригинално",
"playCount": "Пуштано",
"rating": "Рејтинг",
"recordLabel": "Издавачка кућа",
"releaseDate": "Објављено",
"releaseType": "Тип",
"released": "Објављено",
"releases": "Издање|||| Издања",
"size": "Величина",
"songCount": "Песме",
"updatedAt": "Ажурирано",
"year": "Година"
},
"actions": {
"addToPlaylist": "Додај у плејлисту",
"addToQueue": "Пусти касније",
"download": "Преузми",
"info": "Прикажи инфо",
"playAll": "Пусти",
"playNext": "Пусти наредно",
"share": "Дели",
"shuffle": "Измешај"
},
"lists": {
"all": "Све",
"mostPlayed": "Најчешће пуштано",
"random": "Насумично",
"recentlyAdded": "Додато недавно",
"recentlyPlayed": "Пуштано недавно",
"starred": "Омиљено",
"topRated": "Најбоље рангирано"
}
},
"message": {
"note": "НАПОМЕНА",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"noPlaylistsAvailable": "Није доступна ниједна",
"delete_user_title": "Брисање корисника %{name}",
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
"lastfmLink": "Прочитај још...",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
"shareSuccess": "URL је копиран у клипборд: %{url}",
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату"
"artist": {
"name": "Уметник |||| Уметници",
"fields": {
"albumCount": "Број албума",
"genre": "Жанр",
"name": "Назив",
"playCount": "Пуштано",
"rating": "Рејтинг",
"role": "Улога",
"size": "Величина",
"songCount": "Број песама"
},
"roles": {
"albumartist": "Уметник албума |||| Уметници албума",
"arranger": "Аранжер |||| Аранжери",
"artist": "Уметник |||| Уметници",
"composer": "Композитор |||| Композитори",
"conductor": "Диригент |||| Диригенти",
"director": "Режисер |||| Режисери",
"djmixer": "Ди-џеј миксер |||| Ди-џеј миксер",
"engineer": "Инжењер |||| Инжењери",
"lyricist": "Текстописац |||| Текстописци",
"mixer": "Миксер |||| Миксери",
"performer": "Извођач |||| Извођачи",
"producer": родуцент |||| Продуценти",
"remixer": "Ремиксер |||| Ремиксери"
}
},
"menu": {
"library": "Библиотека",
"settings": "Подешавања",
"version": "Верзија",
"theme": "Тема",
"personal": {
"name": "Лична",
"options": {
"theme": "Тема",
"language": "Језик",
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
"replaygain": "ReplayGain режим",
"preAmp": "ReplayGain претпојачање (dB)",
"gain": {
"none": скључено",
"album": "Користи Album појачање",
"track": "Користи Track појачање"
}
}
},
"albumList": "Албуми",
"playlists": "Плејлисте",
"sharedPlaylists": "Дељене плејлисте",
"about": "О"
"user": {
"name": "Корисник |||| Корисници",
"fields": {
"changePassword": "Измени лозинку?",
"createdAt": "Креирана",
"currentPassword": "Текућа лозинка",
"isAdmin": "Да ли је Админ",
"lastAccessAt": "Последњи приступ",
"lastLoginAt": "Последња пријава",
"name": "Назив",
"newPassword": "Нова лозинка",
"password": "Лозинка",
"token": "Жетон",
"updatedAt": "Ажурирано",
"userName": "Корисничко име"
},
"helperTexts": {
"name": змене вашег имена ће постати видљиве након следеће пријаве"
},
"notifications": {
"created": "Корисник креиран",
"deleted": "Корисник обрисан",
"updated": "Корисник ажуриран"
},
"message": {
"clickHereForToken": "Кликните овде да преузмете свој жетон",
"listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон."
}
},
"player": {
"playListsText": "Ред за пуштање",
"openText": "Отвори",
"closeText": "Затвори",
"notContentText": "Нема музике",
"clickToPlayText": "Кликни за пуштање",
"clickToPauseText": "Кликни за паузирање",
"nextTrackText": "Наредна нумера",
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"volumeText": "Јачина",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"destroyText": "Уништи",
"downloadText": "Преузми",
"removeAudioListsText": "Обриши аудио листе",
"clickToDeleteText": "Кликните да обришете %{name}",
"emptyLyricText": "Нема стихова",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
"singleLoop": "Понови једну",
"shufflePlay": "Промешано"
}
"name": "Плејер |||| Плејери",
"fields": {
"client": "Клијент",
"lastSeen": "Последњи пут виђен",
"maxBitRate": "Макс. битски проток",
"name": "Назив",
"reportRealPath": "Пријављуј реалну путању",
"scrobbleEnabled": "Шаљи скроблове на спољне сервисе",
"transcodingId": "Транскодирање",
"userName": "Корисничко име"
}
},
"about": {
"links": {
"homepage": "Почетна страница",
"source": "Изворни кôд",
"featureRequests": "Захтеви за функцијама"
}
"transcoding": {
"name": "Транскодирање |||| Транскодирања",
"fields": {
"command": "Команда",
"defaultBitRate": "Подразумевани битски проток",
"name": "Назив",
"targetFormat": "Циљни формат"
}
},
"activity": {
"title": "Активност",
"totalScanned": "Укупан број скенираних фолдера",
"quickScan": "Брзо скенирање",
"fullScan": "Комплетно скенирање",
"serverUptime": "Сервер се извршава",
"serverDown": "ВАН МРЕЖЕ"
"playlist": {
"name": "Плејлиста |||| Плејлисте",
"fields": {
"comment": "Коментар",
"createdAt": "Креирана",
"duration": "Трајање",
"name": "Назив",
"ownerName": "Власник",
"path": "Увоз из",
"public": "Јавна",
"songCount": "Песме",
"sync": "Ауто-увоз",
"updatedAt": "Ажурирано"
},
"actions": {
"addNewPlaylist": "Креирај „%{name}”",
"export": "Извези",
"makePrivate": "Учини приватном",
"makePublic": "Учини јавном",
"selectPlaylist": "Изабери плејлисту"
},
"message": {
"duplicate_song": "Додај дуплиране песме",
"song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?"
}
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
"show_help": "Прикажи ову помоћ",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
"prev_song": "Претходна песма",
"next_song": "Наредна песма",
"current_song": "Иди на текућу песму",
"vol_up": "Појачај",
"vol_down": "Утишај",
"toggle_love": "Додај ову нумеру у омиљене"
}
"radio": {
"name": "Радио |||| Радији",
"fields": {
"createdAt": "Креирана",
"homePageUrl": "URL почетне странице",
"name": "Назив",
"streamUrl": "URL тока",
"updatedAt": "Ажурирано"
},
"actions": {
"playNow": "Пусти одмах"
}
},
"share": {
"name": "Дељење |||| Дељења",
"fields": {
"contents": "Садржај",
"createdAt": "Креирано",
"description": "Опис",
"downloadable": "Допушта се преузимање?",
"expiresAt": "Истиче",
"format": "Формат",
"lastVisitedAt": "Последњи пут посећено",
"maxBitRate": "Макс. битски проток",
"updatedAt": "Ажурирано",
"url": "URL",
"username": "Поделио",
"visitCount": "Број посета"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "Фајл који недостаје|||| Фајлови који недостају",
"empty": "Нема фајлова који недостају",
"fields": {
"path": "Путања",
"size": "Величина",
"updatedAt": "Нестао дана"
},
"actions": {
"remove": "Уклони"
},
"notifications": {
"removed": "Фајл који недостаје, или више њих, је уклоњен"
}
}
},
"ra": {
"auth": {
"auth_check_error": "Ако желите да наставите, молимо вас да се пријавите",
"buttonCreateAdmin": "Креирај админа",
"confirmPassword": "Потврдите лозинку",
"insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите",
"logout": "Одјави се",
"password": "Лозинка",
"sign_in": "Пријави се",
"sign_in_error": "Потврда идентитета није успела, покушајте поново",
"user_menu": "Профил",
"username": "Корисничко име",
"welcome1": "Хвала што сте инсталирали Navidrome!",
"welcome2": "За почетак, креирајте админ корисника"
},
"validation": {
"email": "Мора да буде исправна и-мејл адреса",
"invalidChars": "Молимо вас да користите само слова и цифре",
"maxLength": "Мора да буде %{max} карактера или мање",
"maxValue": "Мора да буде %{max} или мање",
"minLength": "Мора да буде барем %{min} карактера",
"minValue": "Мора да буде барем %{min}",
"number": "Мора да буде број",
"oneOf": "Мора да буде једно од: %{options}",
"passwordDoesNotMatch": "Лозинка се не подудара",
"regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}",
"required": "Неопходно",
"unique": "Мора да буде јединствено",
"url": "Мора да буде исправна URL адреса"
},
"action": {
"add": "Додај",
"add_filter": "Додај филтер",
"back": "Иди назад",
"bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Откажи",
"clear_input_value": "Обриши вредност",
"clone": "Клонирај",
"close": "Затвори",
"close_menu": "Затвори мени",
"confirm": "Потврди",
"create": "Креирај",
"delete": "Обриши",
"download": "Преузми",
"edit": "Уреди",
"expand": "Развиј",
"export": "Извези",
"list": "Листа",
"open_menu": "Отвори мени",
"refresh": "Освежи",
"remove": "Уклони",
"remove_filter": "Уклони овај филтер",
"save": "Сачувај",
"search": "Тражи",
"share": "Дели",
"show": "Прикажи",
"skip": "Прескочи",
"sort": "Сортирај",
"undo": "Поништи",
"unselect": "Уклони избор"
},
"boolean": {
"false": "Не",
"true": "Да"
},
"page": {
"create": "Креирај %{name}",
"dashboard": "Контролна табла",
"edit": "%{name} #%{id}",
"empty": "Још увек нема %{name}.",
"error": "Нешто је пошло наопако",
"invite": "Желите ли да се дода?",
"list": "%{name}",
"loading": "Учитава се",
"not_found": "Није пронађено",
"show": "%{name} #%{id}"
},
"input": {
"file": {
"upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете."
},
"image": {
"upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.",
"upload_single": "Упустите слику да се отпреми, или кликните да је изаберете."
},
"password": {
"toggle_hidden": "Прикажи лозинку",
"toggle_visible": "Сакриј лозинку"
},
"references": {
"all_missing": "Не могу да се нађу подаци референци.",
"many_missing": "Изгледа да барем једна од придружених референци више није доступна.",
"single_missing": "Изгледа да придружена референца више није доступна."
}
},
"message": {
"about": "О",
"are_you_sure": "Да ли сте сигурни?",
"bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?",
"bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}",
"delete_content": "Да ли заиста желите да обришете ову ставку?",
"delete_title": "Брисање %{name} #%{id}",
"details": "Детаљи",
"error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.",
"invalid_form": "Формулар није исправан. Молимо вас да исправите грешке",
"loading": "Страница се учитава, сачекајте мало",
"no": "Не",
"not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.",
"unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?",
"yes": "Да"
},
"navigation": {
"next": "Наредна",
"no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.",
"no_results": "Није пронађен ниједан резултат",
"page_out_from_begin": "Не може да се иде испред странице 1",
"page_out_from_end": "Не може да се иде након последње странице",
"page_out_of_boundaries": "Број странице %{page} је ван опсега",
"page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}",
"page_rows_per_page": "Ставки по страници:",
"prev": "Претход",
"skip_nav": "Прескочи на садржај"
},
"notification": {
"bad_item": "Неисправни елемент",
"canceled": "Акција је отказана",
"created": "Елемент је креиран",
"data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.",
"deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано",
"http_error": "Грешка у комуникацији са сервером",
"i18n_error": "Не могу да се учитају преводи за наведени језик",
"item_doesnt_exist": "Елемент не постоји",
"logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.",
"new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.",
"updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано"
},
"toggleFieldsMenu": {
"columnsToDisplay": "Колоне за приказ",
"grid": "Мрежа",
"layout": "Распоред",
"table": "Табела"
}
},
"message": {
"delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?",
"delete_user_title": "Брисање корисника %{name}",
"downloadDialogTitle": "Преузимање %{resource} %{name} (%{size})",
"downloadOriginalFormat": "Преузми у оригиналном формату",
"lastfmLink": "Прочитај још...",
"lastfmLinkFailure": "Last.fm није могао да се повеже",
"lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање",
"lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm",
"lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено",
"listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}",
"listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}",
"listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz",
"listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено",
"noPlaylistsAvailable": "Није доступна ниједна",
"note": "НАПОМЕНА",
"notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења",
"notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола",
"openIn": {
"lastfm": "Отвори у Last.fm",
"musicbrainz": "Отвори у MusicBrainz"
},
"remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.",
"remove_missing_title": "Уклони фајлове који недостају",
"shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}",
"shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер",
"shareDialogTitle": "Подели %{resource} %{name}",
"shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд",
"shareOriginalFormat": "Подели у оригиналном формату",
"shareSuccess": "URL је копиран у клипборд: %{url}",
"songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама",
"transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.",
"transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања."
},
"menu": {
"about": "О",
"albumList": "Албуми",
"library": "Библиотека",
"personal": {
"name": "Лична",
"options": {
"defaultView": "Подразумевани поглед",
"desktop_notifications": "Десктоп обавештења",
"gain": {
"album": "Користи Album појачање",
"none": "Искључено",
"track": "Користи Track појачање"
},
"language": "Језик",
"lastfmNotConfigured": "Није подешен Last.fm API-кључ",
"lastfmScrobbling": "Скроблуј на Last.fm",
"listenBrainzScrobbling": "Скроблуј на ListenBrainz",
"preAmp": "ReplayGain претпојачање (dB)",
"replaygain": "ReplayGain режим",
"theme": "Тема"
}
},
"playlists": "Плејлисте",
"settings": "Подешавања",
"sharedPlaylists": "Дељене плејлисте",
"theme": "Тема",
"version": "Верзија"
},
"player": {
"clickToDeleteText": "Кликните да обришете %{name}",
"clickToPauseText": "Кликни за паузирање",
"clickToPlayText": "Кликни за пуштање",
"closeText": "Затвори",
"destroyText": "Уништи",
"downloadText": "Преузми",
"emptyLyricText": "Нема стихова",
"nextTrackText": "Наредна нумера",
"notContentText": "Нема музике",
"openText": "Отвори",
"playListsText": "Ред за пуштање",
"playModeText": {
"order": "По редоследу",
"orderLoop": "Понови",
"shufflePlay": "Измешај",
"singleLoop": "Понови једну"
},
"previousTrackText": "Претходна нумера",
"reloadText": "Поново учитај",
"removeAudioListsText": "Обриши аудио листе",
"toggleLyricText": "Укљ./Искљ. стихове",
"toggleMiniModeText": "Умањи",
"volumeText": "Јачина"
},
"about": {
"links": {
"featureRequests": "Захтеви за функцијама",
"homepage": "Почетна страница",
"insights": {
"disabled": "Искључено",
"waiting": "Чека се"
},
"lastInsightsCollection": "Последња колекција увида",
"source": "Изворни кôд"
}
},
"activity": {
"fullScan": "Комплетно скенирање",
"quickScan": "Брзо скенирање",
"serverDown": "ВАН МРЕЖЕ",
"serverUptime": "Сервер се извршава",
"title": "Активност",
"totalScanned": "Укупан број скенираних фолдера"
},
"help": {
"title": "Navidrome пречице",
"hotkeys": {
"current_song": "Иди на текућу песму",
"next_song": "Наредна песма",
"prev_song": "Претходна песма",
"show_help": "Прикажи ову помоћ",
"toggle_love": "Додај ову нумеру у омиљене",
"toggle_menu": "Укљ./Искљ. бочну траку менија",
"toggle_play": "Пусти / Паузирај",
"vol_down": "Утишај",
"vol_up": "Појачај"
}
}
}

View File

@@ -33,7 +33,8 @@
"tags": "Ek Etiketler",
"mappedTags": "Eşlenen etiketler",
"rawTags": "Ham etiketler",
"bitDepth": "Bit derinliği"
"bitDepth": "Bit derinliği",
"sampleRate": "Örnekleme Oranı"
},
"actions": {
"addToQueue": "Oynatma Sırasına Ekle",

View File

@@ -12,12 +12,14 @@
"artist": "歌手",
"album": "专辑",
"path": "文件路径",
"genre": "类型",
"genre": "流派",
"compilation": "合辑",
"year": "发行年份",
"size": "文件大小",
"updatedAt": "更新于",
"bitRate": "比特率",
"bitDepth": "比特深度",
"channels": "声道",
"discSubtitle": "字幕",
"starred": "收藏",
"comment": "注释",
@@ -25,8 +27,13 @@
"quality": "品质",
"bpm": "BPM",
"playDate": "最后一次播放",
"channels": "声道",
"createdAt": "创建于"
"createdAt": "创建于",
"grouping": "分组",
"mood": "情绪",
"participants": "其他参与人员",
"tags": "附加标签",
"mappedTags": "映射标签",
"rawTags": "原始标签"
},
"actions": {
"addToQueue": "加入播放列表",
@@ -46,29 +53,36 @@
"duration": "时长",
"songCount": "歌曲数量",
"playCount": "播放次数",
"size": "文件大小",
"name": "名称",
"genre": "类型",
"genre": "流派",
"compilation": "合辑",
"year": "发行年份",
"date": "录制日期",
"originalDate": "原始日期",
"releaseDate": "发⾏日期",
"releases": "发⾏",
"released": "已发⾏",
"updatedAt": "更新于",
"comment": "注释",
"rating": "评分",
"createdAt": "创建于",
"size": "文件大小",
"originalDate": "原始日期",
"releaseDate": "发⾏日期",
"releases": "发⾏",
"released": "已发⾏"
"recordLabel": "厂牌",
"catalogNum": "目录编号",
"releaseType": "发行类型",
"grouping": "分组",
"media": "媒体类型",
"mood": "情绪"
},
"actions": {
"playAll": "立即播放",
"playNext": "下首播放",
"addToQueue": "加入播放列表",
"share": "分享",
"shuffle": "随机播放",
"addToPlaylist": "加入歌单",
"download": "下载",
"info": "查看信息",
"share": "分享"
"info": "查看信息"
},
"lists": {
"all": "所有",
@@ -86,10 +100,26 @@
"name": "名称",
"albumCount": "专辑数",
"songCount": "歌曲数",
"size": "文件大小",
"playCount": "播放次数",
"rating": "评分",
"genre": "类型",
"size": "文件大小"
"genre": "流派",
"role": "参与角色"
},
"roles": {
"albumartist": "专辑歌手",
"artist": "歌手",
"composer": "作曲",
"conductor": "指挥",
"lyricist": "作词",
"arranger": "编曲",
"producer": "制作人",
"director": "总监",
"engineer": "工程师",
"mixer": "混音师",
"remixer": "重混师",
"djmixer": "DJ混音师",
"performer": "演奏家"
}
},
"user": {
@@ -98,6 +128,7 @@
"userName": "用户名",
"isAdmin": "是否管理员",
"lastLoginAt": "上次登录",
"lastAccessAt": "上次访问",
"updatedAt": "更新于",
"name": "名称",
"password": "密码",
@@ -108,7 +139,7 @@
"token": "令牌"
},
"helperTexts": {
"name": "你名字的更改将在下次登录生效"
"name": "名称的更改将在下次登录生效"
},
"notifications": {
"created": "用户已创建",
@@ -187,6 +218,7 @@
"username": "分享者",
"url": "链接",
"description": "描述",
"downloadable": "是否允许下载?",
"contents": "目录",
"expiresAt": "过期于",
"lastVisitedAt": "上次访问于",
@@ -194,8 +226,24 @@
"format": "格式",
"maxBitRate": "最大比特率",
"updatedAt": "更新于",
"createdAt": "创建于",
"downloadable": "是否允许下载"
"createdAt": "创建于"
},
"notifications": {},
"actions": {}
},
"missing": {
"name": "丢失文件",
"empty": "无丢失文件",
"fields": {
"path": "路径",
"size": "文件大小",
"updatedAt": "丢失于"
},
"actions": {
"remove": "移除"
},
"notifications": {
"removed": "丢失文件已移除"
}
}
},
@@ -211,7 +259,8 @@
"password": "密码",
"sign_in": "登录",
"sign_in_error": "验证失败,请重试",
"logout": "注销"
"logout": "注销",
"insightsCollectionNote": "Navidrome 会收集匿名使用数据以协助改进项目。\n点击[此处]了解详情或选择退出。"
},
"validation": {
"invalidChars": "请使用字母和数字",
@@ -233,6 +282,7 @@
"add": "添加",
"back": "返回",
"bulk_actions": "选中 %{smart_count} 项",
"bulk_actions_mobile": "%{smart_count}",
"cancel": "取消",
"clear_input_value": "清除",
"clone": "复制",
@@ -256,7 +306,6 @@
"close_menu": "关闭菜单",
"unselect": "未选择",
"skip": "跳过",
"bulk_actions_mobile": "%{smart_count}",
"share": "分享",
"download": "下载"
},
@@ -351,29 +400,31 @@
"noPlaylistsAvailable": "没有有效的歌单",
"delete_user_title": "删除用户 %{name}",
"delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?",
"remove_missing_title": "移除丢失文件",
"remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。",
"notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知",
"notifications_not_available": "此浏览器不支持桌面通知",
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
"lastfmLinkFailure": "Last.fm 无法关联",
"lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用",
"lastfmUnlinkFailure": "Last.fm 无法取消关联",
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
"openIn": {
"lastfm": "在 Last.fm 中打开",
"musicbrainz": "在 MusicBrainz 中打开"
},
"lastfmLink": "查看更多…",
"listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录",
"listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}",
"listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用",
"listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联",
"downloadOriginalFormat": "下载原始格式",
"shareOriginalFormat": "分享原始格式",
"shareDialogTitle": "分享 %{resource} '%{name}'",
"shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}",
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter",
"shareSuccess": "分享链接已复制: %{url}",
"shareFailure": "分享链接复制失败: %{url}",
"downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter"
"downloadOriginalFormat": "下载原始格式"
},
"menu": {
"library": "曲库",
@@ -387,6 +438,7 @@
"language": "语言",
"defaultView": "默认界面",
"desktop_notifications": "桌面通知",
"lastfmNotConfigured": "没有配置 Last.fm 的 API-Key",
"lastfmScrobbling": "启用 Last.fm 的喜好记录",
"listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录",
"replaygain": "回放增益",
@@ -399,9 +451,9 @@
}
},
"albumList": "专辑",
"about": "关于",
"playlists": "歌单",
"sharedPlaylists": "共享的歌单"
"sharedPlaylists": "共享的歌单",
"about": "关于"
},
"player": {
"playListsText": "播放列表",
@@ -432,7 +484,12 @@
"links": {
"homepage": "主页",
"source": "源代码",
"featureRequests": "功能需求"
"featureRequests": "功能需求",
"lastInsightsCollection": " 最近的分析收集",
"insights": {
"disabled": "禁用",
"waiting": "等待"
}
}
},
"activity": {
@@ -451,10 +508,10 @@
"toggle_play": "播放/暂停",
"prev_song": "上一首歌",
"next_song": "下一首歌",
"current_song": "转到当前播放",
"vol_up": "增大音量",
"vol_down": "减小音量",
"toggle_love": "添加/移除星标",
"current_song": "转到当前播放"
"toggle_love": "添加/移除星标"
}
}
}

View File

@@ -185,7 +185,6 @@ const AlbumSongs = (props) => {
{...props}
hasBulkActions={true}
showDiscSubtitles={true}
showReleaseDivider={true}
contextAlwaysVisible={!isDesktop}
classes={{ row: classes.row }}
>

View File

@@ -231,7 +231,6 @@ export const AlbumContextMenu = (props) =>
sort: { field: 'album', order: 'ASC' },
filter: {
album_id: props.record.id,
release_date: props.releaseDate,
disc_number: props.discNumber,
missing: false,
},

View File

@@ -24,7 +24,6 @@ export const PlayButton = ({ record, size, className }) => {
sort: { field: 'album', order: 'ASC' },
filter: {
album_id: record.id,
release_date: record.releaseDate,
disc_number: record.discNumber,
},
})

View File

@@ -59,59 +59,12 @@ const useStyles = makeStyles({
},
})
const ReleaseRow = forwardRef(
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const classes = useStyles({ isDesktop })
const translate = useTranslate()
const handlePlaySubset = (releaseDate) => () => {
onClick(releaseDate)
}
let releaseTitle = []
if (record.releaseDate) {
releaseTitle.push(translate('resources.album.fields.released'))
releaseTitle.push(formatFullDate(record.releaseDate))
if (record.catalogNum && isDesktop) {
releaseTitle.push('· Cat #')
releaseTitle.push(record.catalogNum)
}
}
return (
<TableRow
hover
ref={ref}
onClick={handlePlaySubset(record.releaseDate)}
className={classes.row}
>
<TableCell colSpan={colSpan}>
<Typography variant="h6" className={classes.subtitle}>
{releaseTitle.join(' ')}
</Typography>
</TableCell>
<TableCell>
<AlbumContextMenu
record={{ id: record.albumId }}
releaseDate={record.releaseDate}
showLove={false}
className={classes.contextMenu}
visible={contextAlwaysVisible}
/>
</TableCell>
</TableRow>
)
},
)
ReleaseRow.displayName = 'ReleaseRow'
const DiscSubtitleRow = forwardRef(
({ record, onClick, colSpan, contextAlwaysVisible }, ref) => {
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const classes = useStyles({ isDesktop })
const handlePlaySubset = (releaseDate, discNumber) => () => {
onClick(releaseDate, discNumber)
const handlePlaySubset = (discNumber) => () => {
onClick(discNumber)
}
let subtitle = []
@@ -126,7 +79,7 @@ const DiscSubtitleRow = forwardRef(
<TableRow
hover
ref={ref}
onClick={handlePlaySubset(record.releaseDate, record.discNumber)}
onClick={handlePlaySubset(record.discNumber)}
className={classes.row}
>
<TableCell colSpan={colSpan}>
@@ -139,7 +92,6 @@ const DiscSubtitleRow = forwardRef(
<AlbumContextMenu
record={{ id: record.albumId }}
discNumber={record.discNumber}
releaseDate={record.releaseDate}
showLove={false}
className={classes.contextMenu}
hideShare={true}
@@ -158,7 +110,6 @@ export const SongDatagridRow = ({
record,
children,
firstTracksOfDiscs,
firstTracksOfReleases,
contextAlwaysVisible,
onClickSubset,
className,
@@ -176,7 +127,6 @@ export const SongDatagridRow = ({
discs: [
{
albumId: record?.albumId,
releaseDate: record?.releaseDate,
discNumber: record?.discNumber,
},
],
@@ -209,15 +159,6 @@ export const SongDatagridRow = ({
const childCount = fields.length
return (
<>
{firstTracksOfReleases.has(record.id) && (
<ReleaseRow
ref={dragDiscRef}
record={record}
onClick={onClickSubset}
contextAlwaysVisible={contextAlwaysVisible}
colSpan={childCount + (rest.expand ? 1 : 0)}
/>
)}
{firstTracksOfDiscs.has(record.id) && (
<DiscSubtitleRow
ref={dragDiscRef}
@@ -244,7 +185,6 @@ SongDatagridRow.propTypes = {
record: PropTypes.object,
children: PropTypes.node,
firstTracksOfDiscs: PropTypes.instanceOf(Set),
firstTracksOfReleases: PropTypes.instanceOf(Set),
contextAlwaysVisible: PropTypes.bool,
onClickSubset: PropTypes.func,
}
@@ -256,23 +196,16 @@ SongDatagridRow.defaultProps = {
const SongDatagridBody = ({
contextAlwaysVisible,
showDiscSubtitles,
showReleaseDivider,
...rest
}) => {
const dispatch = useDispatch()
const { ids, data } = rest
const playSubset = useCallback(
(releaseDate, discNumber) => {
(discNumber) => {
let idsToPlay = []
if (discNumber !== undefined) {
idsToPlay = ids.filter(
(id) =>
data[id].releaseDate === releaseDate &&
data[id].discNumber === discNumber,
)
} else {
idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate)
idsToPlay = ids.filter((id) => data[id].discNumber === discNumber)
}
dispatch(
playTracks(
@@ -297,8 +230,7 @@ const SongDatagridBody = ({
foundSubtitle = foundSubtitle || data[id].discSubtitle
if (
acc.length === 0 ||
(last && data[id].discNumber !== data[last].discNumber) ||
(last && data[id].releaseDate !== data[last].releaseDate)
(last && data[id].discNumber !== data[last].discNumber)
) {
acc.push(id)
}
@@ -311,37 +243,12 @@ const SongDatagridBody = ({
return set
}, [ids, data, showDiscSubtitles])
const firstTracksOfReleases = useMemo(() => {
if (!ids) {
return new Set()
}
const set = new Set(
ids
.filter((i) => data[i])
.reduce((acc, id) => {
const last = acc && acc[acc.length - 1]
if (
acc.length === 0 ||
(last && data[id].releaseDate !== data[last].releaseDate)
) {
acc.push(id)
}
return acc
}, []),
)
if (!showReleaseDivider || set.size < 2) {
set.clear()
}
return set
}, [ids, data, showReleaseDivider])
return (
<PureDatagridBody
{...rest}
row={
<SongDatagridRow
firstTracksOfDiscs={firstTracksOfDiscs}
firstTracksOfReleases={firstTracksOfReleases}
contextAlwaysVisible={contextAlwaysVisible}
onClickSubset={playSubset}
/>
@@ -353,7 +260,6 @@ const SongDatagridBody = ({
export const SongDatagrid = ({
contextAlwaysVisible,
showDiscSubtitles,
showReleaseDivider,
...rest
}) => {
const classes = useStyles()
@@ -366,7 +272,6 @@ export const SongDatagrid = ({
<SongDatagridBody
contextAlwaysVisible={contextAlwaysVisible}
showDiscSubtitles={showDiscSubtitles}
showReleaseDivider={showReleaseDivider}
/>
}
/>
@@ -376,6 +281,5 @@ export const SongDatagrid = ({
SongDatagrid.propTypes = {
contextAlwaysVisible: PropTypes.bool,
showDiscSubtitles: PropTypes.bool,
showReleaseDivider: PropTypes.bool,
classes: PropTypes.object,
}

View File

@@ -75,6 +75,7 @@ export const SongInfo = (props) => {
compilation: <BooleanField source="compilation" />,
bitRate: <BitrateField source="bitRate" />,
bitDepth: <NumberField source="bitDepth" />,
sampleRate: <NumberField source="sampleRate" />,
channels: <NumberField source="channels" />,
size: <SizeField source="size" />,
updatedAt: <DateField source="updatedAt" showTime />,
@@ -92,7 +93,14 @@ export const SongInfo = (props) => {
roles.push([name, record.participants[name].length])
}
const optionalFields = ['discSubtitle', 'comment', 'bpm', 'genre', 'bitDepth']
const optionalFields = [
'discSubtitle',
'comment',
'bpm',
'genre',
'bitDepth',
'sampleRate',
]
optionalFields.forEach((field) => {
!record[field] && delete data[field]
})

View File

@@ -19,6 +19,7 @@
"updatedAt": "Updated at",
"bitRate": "Bit rate",
"bitDepth": "Bit depth",
"sampleRate": "Sample rate",
"channels": "Channels",
"discSubtitle": "Disc Subtitle",
"starred": "Favourite",