mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-01 19:38:06 -05:00
Compare commits
32 Commits
v0.55.2
...
plugins-mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d7ec306e | ||
|
|
bcc3643c81 | ||
|
|
97b101685e | ||
|
|
8660cb4fff | ||
|
|
ae93e555c9 | ||
|
|
2f71516dde | ||
|
|
73da7550d6 | ||
|
|
674129a34b | ||
|
|
fb0714562d | ||
|
|
6b89f7ab63 | ||
|
|
c548168503 | ||
|
|
8ebefe4065 | ||
|
|
6e59060a01 | ||
|
|
be9e10db37 | ||
|
|
9c20520d59 | ||
|
|
8b754a7c73 | ||
|
|
8326a20eda | ||
|
|
51567a0bdf | ||
|
|
4944f8035a | ||
|
|
0d5097d888 | ||
|
|
ed7ee3d9f8 | ||
|
|
74803bb43e | ||
|
|
0159cf73e2 | ||
|
|
ac1d51f9d0 | ||
|
|
91eb661db5 | ||
|
|
524d508916 | ||
|
|
a6f1f7b7e3 | ||
|
|
49b8cfc261 | ||
|
|
bcea8b832a | ||
|
|
58367afaea | ||
|
|
6b59f5f73a | ||
|
|
5f0c1e7387 |
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: "2"
|
||||
run:
|
||||
build-tags:
|
||||
- netgo
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
@@ -11,42 +11,48 @@ linters:
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errorlint
|
||||
- gocyclo
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- nilerr
|
||||
- rowserrcheck
|
||||
- staticcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: scanner2
|
||||
linters:
|
||||
- unused
|
||||
|
||||
linters-settings:
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- deprecatedComment
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149
|
||||
disable:
|
||||
- staticcheck
|
||||
settings:
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- deprecatedComment
|
||||
gosec:
|
||||
excludes:
|
||||
- G501
|
||||
- G401
|
||||
- G505
|
||||
- G115
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
2
Makefile
2
Makefile
@@ -49,7 +49,7 @@ testall: testrace ##@Development Run Go and JS tests
|
||||
.PHONY: testall
|
||||
|
||||
lint: ##@Development Lint Go code
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
|
||||
.PHONY: lint
|
||||
|
||||
lintall: lint ##@Development Lint Go and JS code
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
@@ -66,8 +67,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
@@ -80,7 +81,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -90,8 +91,8 @@ func CreatePublicRouter() *public.Router {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
transcodingCache := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
@@ -134,8 +135,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
@@ -150,8 +151,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||
fileCache := artwork.GetImageCache()
|
||||
fFmpeg := ffmpeg.New()
|
||||
agentsAgents := agents.GetAgents(dataStore)
|
||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
||||
provider := external.NewProvider(dataStore, agentsAgents)
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||
broker := events.GetBroker()
|
||||
playlists := core.NewPlaylists(dataStore)
|
||||
|
||||
@@ -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
2
core/agents/mcp/mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
mcp-server
|
||||
*.wasm
|
||||
17
core/agents/mcp/mcp-server/README.md
Normal file
17
core/agents/mcp/mcp-server/README.md
Normal 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`).
|
||||
172
core/agents/mcp/mcp-server/dbpedia.go
Normal file
172
core/agents/mcp/mcp-server/dbpedia.go
Normal 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
|
||||
}
|
||||
16
core/agents/mcp/mcp-server/fetch.go
Normal file
16
core/agents/mcp/mcp-server/fetch.go
Normal 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)
|
||||
}
|
||||
83
core/agents/mcp/mcp-server/fetch_native.go
Normal file
83
core/agents/mcp/mcp-server/fetch_native.go
Normal 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
|
||||
}
|
||||
171
core/agents/mcp/mcp-server/fetch_wasm.go
Normal file
171
core/agents/mcp/mcp-server/fetch_wasm.go
Normal 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)
|
||||
}
|
||||
19
core/agents/mcp/mcp-server/go.mod
Normal file
19
core/agents/mcp/mcp-server/go.mod
Normal 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
|
||||
)
|
||||
34
core/agents/mcp/mcp-server/go.sum
Normal file
34
core/agents/mcp/mcp-server/go.sum
Normal 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=
|
||||
289
core/agents/mcp/mcp-server/main.go
Normal file
289
core/agents/mcp/mcp-server/main.go
Normal 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")
|
||||
}
|
||||
205
core/agents/mcp/mcp-server/wikidata.go
Normal file
205
core/agents/mcp/mcp-server/wikidata.go
Normal 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
|
||||
}
|
||||
121
core/agents/mcp/mcp-server/wikipedia.go
Normal file
121
core/agents/mcp/mcp-server/wikipedia.go
Normal 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
|
||||
}
|
||||
207
core/agents/mcp/mcp_agent.go
Normal file
207
core/agents/mcp/mcp_agent.go
Normal 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)
|
||||
}
|
||||
237
core/agents/mcp/mcp_agent_test.go
Normal file
237
core/agents/mcp/mcp_agent_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
189
core/agents/mcp/mcp_host_functions.go
Normal file
189
core/agents/mcp/mcp_host_functions.go
Normal 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
|
||||
}
|
||||
252
core/agents/mcp/mcp_process_native.go
Normal file
252
core/agents/mcp/mcp_process_native.go
Normal 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
|
||||
}
|
||||
305
core/agents/mcp/mcp_process_wazero.go
Normal file
305
core/agents/mcp/mcp_process_wazero.go
Normal 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
|
||||
}
|
||||
17
core/agents/mcp/mcp_suite_test.go
Normal file
17
core/agents/mcp/mcp_suite_test.go
Normal 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")
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -24,15 +24,15 @@ type Artwork interface {
|
||||
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
|
||||
}
|
||||
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em}
|
||||
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
|
||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
|
||||
}
|
||||
|
||||
type artwork struct {
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
em core.ExternalMetadata
|
||||
ds model.DataStore
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
provider external.Provider
|
||||
}
|
||||
|
||||
type artworkReader interface {
|
||||
@@ -115,9 +115,9 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
||||
} else {
|
||||
switch artID.Kind {
|
||||
case model.KindArtistArtwork:
|
||||
artReader, err = newArtistReader(ctx, a, artID, a.em)
|
||||
artReader, err = newArtistReader(ctx, a, artID, a.provider)
|
||||
case model.KindAlbumArtwork:
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
|
||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||
case model.KindMediaFileArtwork:
|
||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||
case model.KindPlaylistArtwork:
|
||||
|
||||
@@ -6,12 +6,14 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@@ -19,14 +21,14 @@ import (
|
||||
type albumArtworkReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
em core.ExternalMetadata
|
||||
provider external.Provider
|
||||
album model.Album
|
||||
updatedAt *time.Time
|
||||
imgFiles []string
|
||||
rootFolder string
|
||||
}
|
||||
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*albumArtworkReader, error) {
|
||||
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
|
||||
al, err := artwork.ds.Album(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -37,7 +39,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
||||
}
|
||||
a := &albumArtworkReader{
|
||||
a: artwork,
|
||||
em: em,
|
||||
provider: provider,
|
||||
album: *al,
|
||||
updatedAt: imagesUpdateAt,
|
||||
imgFiles: imgFiles,
|
||||
@@ -82,7 +84,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
|
||||
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
|
||||
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
|
||||
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
|
||||
case len(a.imgFiles) > 0:
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
|
||||
}
|
||||
@@ -112,5 +114,10 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
|
||||
imgFiles = append(imgFiles, filepath.Join(path, img))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort image files to ensure consistent selection of cover art
|
||||
// This prioritizes files from lower-numbered disc folders by sorting the paths
|
||||
slices.Sort(imgFiles)
|
||||
|
||||
return paths, imgFiles, &updatedAt, nil
|
||||
}
|
||||
|
||||
76
core/artwork/reader_album_test.go
Normal file
76
core/artwork/reader_album_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Album Artwork Reader", func() {
|
||||
Describe("loadAlbumFoldersPaths", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
ds *fakeDataStore
|
||||
repo *fakeFolderRepo
|
||||
album model.Album
|
||||
now time.Time
|
||||
expectedAt time.Time
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
now = time.Now().Truncate(time.Second)
|
||||
expectedAt = now.Add(5 * time.Minute)
|
||||
|
||||
// Set up the test folders with image files
|
||||
repo = &fakeFolderRepo{
|
||||
result: []model.Folder{
|
||||
{
|
||||
Path: "Artist/Album/Disc1",
|
||||
ImagesUpdatedAt: expectedAt,
|
||||
ImageFiles: []string{"cover.jpg", "back.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc10",
|
||||
ImagesUpdatedAt: now,
|
||||
ImageFiles: []string{"cover.jpg"},
|
||||
},
|
||||
},
|
||||
err: nil,
|
||||
}
|
||||
ds = &fakeDataStore{
|
||||
folderRepo: repo,
|
||||
}
|
||||
album = model.Album{
|
||||
ID: "album1",
|
||||
Name: "Album",
|
||||
FolderIDs: []string{"folder1", "folder2", "folder3"},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns sorted image files", func() {
|
||||
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
|
||||
|
||||
// Check that image files are sorted alphabetically
|
||||
Expect(imgFiles).To(HaveLen(4))
|
||||
|
||||
// The files should be sorted by full path
|
||||
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
|
||||
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
|
||||
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
|
||||
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
@@ -22,13 +23,13 @@ import (
|
||||
type artistReader struct {
|
||||
cacheKey
|
||||
a *artwork
|
||||
em core.ExternalMetadata
|
||||
provider external.Provider
|
||||
artist model.Artist
|
||||
artistFolder string
|
||||
imgFiles []string
|
||||
}
|
||||
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*artistReader, error) {
|
||||
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
|
||||
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -53,7 +54,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
||||
}
|
||||
a := &artistReader{
|
||||
a: artwork,
|
||||
em: em,
|
||||
provider: provider,
|
||||
artist: *ar,
|
||||
artistFolder: artistFolder,
|
||||
imgFiles: imgFiles,
|
||||
@@ -95,7 +96,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em))
|
||||
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
|
||||
case strings.HasPrefix(pattern, "album/"):
|
||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||
default:
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -157,9 +157,9 @@ func fromAlbumPlaceholder() sourceFunc {
|
||||
return r, consts.PlaceholderAlbumArt, nil
|
||||
}
|
||||
}
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
|
||||
func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := em.ArtistImage(ctx, ar.ID)
|
||||
imageUrl, err := provider.ArtistImage(ctx, ar.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -168,9 +168,9 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.Exte
|
||||
}
|
||||
}
|
||||
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, em core.ExternalMetadata) sourceFunc {
|
||||
func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
imageUrl, err := em.AlbumImage(ctx, al.ID)
|
||||
imageUrl, err := provider.AlbumImage(ctx, al.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
55
core/common_test.go
Normal file
55
core/common_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
270
core/external/extdata_helper_test.go
vendored
Normal file
270
core/external/extdata_helper_test.go
vendored
Normal file
@@ -0,0 +1,270 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Shared Mock Implementations ---
|
||||
|
||||
// mockArtistRepo mocks model.ArtistRepository
|
||||
type mockArtistRepo struct {
|
||||
mock.Mock
|
||||
model.ArtistRepository
|
||||
}
|
||||
|
||||
func newMockArtistRepo() *mockArtistRepo {
|
||||
return &mockArtistRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||
for _, a := range artists {
|
||||
artistCopy := a
|
||||
m.On("Get", artistCopy.ID).Return(&artistCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Artist), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Artists), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockArtistRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName is a helper to set up a GetAll expectation for finding by name.
|
||||
func (m *mockArtistRepo) FindByName(name string, artist model.Artist) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Once()
|
||||
}
|
||||
|
||||
// mockMediaFileRepo mocks model.MediaFileRepository
|
||||
type mockMediaFileRepo struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||
return &mockMediaFileRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||
for _, mf := range mediaFiles {
|
||||
mfCopy := mf
|
||||
m.On("Get", mfCopy.ID).Return(&mfCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.MediaFile), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByMBID is a helper to set up a GetAll expectation for finding by MBID.
|
||||
func (m *mockMediaFileRepo) FindByMBID(mbid string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// FindByArtistAndTitle is a helper to set up a GetAll expectation for finding by artist/title.
|
||||
func (m *mockMediaFileRepo) FindByArtistAndTitle(artistID string, title string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// mockAlbumRepo mocks model.AlbumRepository
|
||||
type mockAlbumRepo struct {
|
||||
mock.Mock
|
||||
model.AlbumRepository
|
||||
}
|
||||
|
||||
func newMockAlbumRepo() *mockAlbumRepo {
|
||||
return &mockAlbumRepo{}
|
||||
}
|
||||
|
||||
// Get implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Album), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Albums), args.Error(1)
|
||||
}
|
||||
|
||||
// mockSimilarArtistAgent mocks agents implementing ArtistTopSongsRetriever and ArtistSimilarRetriever
|
||||
type mockSimilarArtistAgent struct {
|
||||
mock.Mock
|
||||
agents.Interface // Embed to satisfy methods not explicitly mocked
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) AgentName() string {
|
||||
return "mockSimilar"
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
// mockAgents mocks the main Agents interface used by Provider
|
||||
type mockAgents struct {
|
||||
mock.Mock // Embed testify mock
|
||||
topSongsAgent agents.ArtistTopSongsRetriever
|
||||
similarAgent agents.ArtistSimilarRetriever
|
||||
imageAgent agents.ArtistImageRetriever
|
||||
albumInfoAgent agents.AlbumInfoRetriever
|
||||
bioAgent agents.ArtistBiographyRetriever
|
||||
mbidAgent agents.ArtistMBIDRetriever
|
||||
urlAgent agents.ArtistURLRetriever
|
||||
agents.Interface
|
||||
}
|
||||
|
||||
func (m *mockAgents) AgentName() string {
|
||||
return "mockCombined"
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
if m.similarAgent != nil {
|
||||
return m.similarAgent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
if m.topSongsAgent != nil {
|
||||
return m.topSongsAgent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
|
||||
}
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
if m.albumInfoAgent != nil {
|
||||
return m.albumInfoAgent.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
}
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
if m.mbidAgent != nil {
|
||||
return m.mbidAgent.GetArtistMBID(ctx, id, name)
|
||||
}
|
||||
args := m.Called(ctx, id, name)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.urlAgent != nil {
|
||||
return m.urlAgent.GetArtistURL(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.bioAgent != nil {
|
||||
return m.bioAgent.GetArtistBiography(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
if m.imageAgent != nil {
|
||||
return m.imageAgent.GetArtistImages(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
17
core/external/extdata_suite_test.go
vendored
Normal file
17
core/external/extdata_suite_test.go
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestExternal(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "External Suite")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -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"
|
||||
@@ -31,7 +32,7 @@ const (
|
||||
refreshQueueLength = 2000
|
||||
)
|
||||
|
||||
type ExternalMetadata interface {
|
||||
type Provider interface {
|
||||
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
@@ -40,9 +41,9 @@ type ExternalMetadata interface {
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
}
|
||||
|
||||
type externalMetadata struct {
|
||||
type provider struct {
|
||||
ds model.DataStore
|
||||
ag *agents.Agents
|
||||
ag Agents
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
@@ -57,14 +58,24 @@ type auxArtist struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
||||
e := &externalMetadata{ds: ds, ag: agents}
|
||||
type Agents interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.ArtistBiographyRetriever
|
||||
agents.ArtistMBIDRetriever
|
||||
agents.ArtistImageRetriever
|
||||
agents.ArtistSimilarRetriever
|
||||
agents.ArtistTopSongsRetriever
|
||||
agents.ArtistURLRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
e := &provider{ds: ds, ag: agents}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
@@ -81,10 +92,11 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, e
|
||||
default:
|
||||
return auxAlbum{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
log.Info(ctx, "Not found", "id", id)
|
||||
@@ -109,7 +121,7 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
start := time.Now()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
@@ -155,7 +167,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
@@ -177,7 +189,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist,
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -187,7 +199,7 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
||||
func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
@@ -211,7 +223,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (au
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
if artist.MbzArtistID == "" {
|
||||
@@ -246,7 +258,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -304,7 +316,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
||||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -318,24 +330,35 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
return nil, agents.ErrNotFound
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(imageUrl)
|
||||
}
|
||||
|
||||
func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumInfo call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "AlbumImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
|
||||
if info == nil {
|
||||
log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
// Return the biggest image
|
||||
@@ -346,26 +369,37 @@ func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL,
|
||||
}
|
||||
}
|
||||
if img.URL == "" {
|
||||
return nil, agents.ErrNotFound
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(img.URL)
|
||||
}
|
||||
|
||||
func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.findArtistByName(ctx, artistName)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "TopSongs not found", "name", artistName)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "TopSongs call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -386,10 +420,11 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
|
||||
} else {
|
||||
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
|
||||
}
|
||||
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
|
||||
if mbid != "" {
|
||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
@@ -420,7 +455,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
||||
return &mfs[0], nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -428,7 +463,7 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
|
||||
artist.ExternalUrl = artisURL
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -438,7 +473,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -456,7 +491,7 @@ func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.Artist
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||
if len(similar) == 0 || err != nil {
|
||||
@@ -471,7 +506,7 @@ func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.Arti
|
||||
artist.SimilarArtists = sa
|
||||
}
|
||||
|
||||
func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
|
||||
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
|
||||
var result model.Artists
|
||||
var notPresent []string
|
||||
|
||||
@@ -515,7 +550,7 @@ func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agen
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
@@ -533,7 +568,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
var ids []string
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if sa.ID == "" {
|
||||
303
core/external/provider_albumimage_test.go
vendored
Normal file
303
core/external/provider_albumimage_test.go
vendored
Normal file
@@ -0,0 +1,303 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - AlbumImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockAlbumAgent *mockAlbumInfoAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockAlbum" // Configure mock agent
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
albumInfoAgent: mockAlbumAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
// Default mock for non-existent entities - Use Maybe() for flexibility
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist name
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the album is not found in the DB", func() {
|
||||
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "not-found")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
agentErr := errors.New("agent failure")
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("agent failure"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{Images: []agents.ExternalImage{}}, nil).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(ctx)
|
||||
// Mock the necessary DB calls *before* canceling the context
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Expect the agent call even if context is cancelled, returning the context error
|
||||
mockAlbumAgent.On("GetAlbumInfo", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
|
||||
// Cancel the context *before* calling the function under test
|
||||
cancelCtx()
|
||||
|
||||
imgURL, err := provider.AlbumImage(cctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("context canceled"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
// Agent should now be called, verify this expectation
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", cctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("derives album ID from MediaFile ID", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumInfo", ctx, "Album One", "", "").
|
||||
Return(&agents.AlbumInfo{
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumInfo", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if deriving album ID fails", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
|
||||
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
// mockAlbumInfoAgent implementation
|
||||
type mockAlbumInfoAgent struct {
|
||||
mock.Mock
|
||||
agents.AlbumInfoRetriever // Embed interface
|
||||
}
|
||||
|
||||
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
|
||||
m := new(mockAlbumInfoAgent)
|
||||
m.On("AgentName").Return("mockAlbum").Maybe()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interface
|
||||
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
301
core/external/provider_artistimage_test.go
vendored
Normal file
301
core/external/provider_artistimage_test.go
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - ArtistImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockImageAgent *mockArtistImageAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockImage" // Configure only the mock agent
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockImageAgent = newMockArtistImageAgent()
|
||||
|
||||
// Use the mockAgents from helper, setting the specific agent
|
||||
agentsCombined = &mockAgents{
|
||||
imageAgent: mockImageAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks for successful Get calls
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe()
|
||||
// Default mock for non-existent entities
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
|
||||
// Default successful image agent response
|
||||
mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Maybe()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
mockArtistRepo.AssertExpectations(GinkgoT())
|
||||
mockAlbumRepo.AssertExpectations(GinkgoT())
|
||||
mockMediaFileRepo.AssertExpectations(GinkgoT())
|
||||
mockImageAgent.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the artist is not found in the DB", func() {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "not-found")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
agentErr := errors.New("agent failure")
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled before agent call", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(context.Background())
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) {
|
||||
cancelCtx() // Cancel context *during* the DB call simulation
|
||||
}).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(cctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
})
|
||||
|
||||
It("derives artist ID from MediaFile ID", func() {
|
||||
// Arrange: Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("derives artist ID from Album ID", func() {
|
||||
// Arrange: Add mock for the initial GetEntityByID lookup
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "album-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if derived artist is not found", func() {
|
||||
// Arrange
|
||||
// Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once()
|
||||
// Add expectation for the recursive GetEntityByID call for the MediaFileRepo
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
// The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/medium.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
})
|
||||
|
||||
// mockArtistImageAgent implementation using testify/mock
|
||||
// This remains local as it's specific to testing the ArtistImage functionality
|
||||
type mockArtistImageAgent struct {
|
||||
mock.Mock
|
||||
agents.ArtistImageRetriever // Embed interface
|
||||
}
|
||||
|
||||
// Constructor for the mock agent
|
||||
func newMockArtistImageAgent() *mockArtistImageAgent {
|
||||
mock := new(mockArtistImageAgent)
|
||||
// Set default AgentName if needed, although usually called via mockAgents
|
||||
mock.On("AgentName").Return("mockImage").Maybe()
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid)
|
||||
// Need careful type assertion for potentially nil slice
|
||||
var res []agents.ExternalImage
|
||||
if args.Get(0) != nil {
|
||||
res = args.Get(0).([]agents.ExternalImage)
|
||||
}
|
||||
return res, args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interface
|
||||
var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil)
|
||||
198
core/external/provider_similarsongs_test.go
vendored
Normal file
198
core/external/provider_similarsongs_test.go
vendored
Normal file
@@ -0,0 +1,198 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - SimilarSongs", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var mockAgent *mockSimilarArtistAgent
|
||||
var mockTopAgent agents.ArtistTopSongsRetriever
|
||||
var mockSimilarAgent agents.ArtistSimilarRetriever
|
||||
var agentsCombined Agents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
It("returns similar songs from main artist and similar artists", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
|
||||
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
similarAgentsResp := []agents.Artist{
|
||||
{Name: "Similar Artist", MBID: "similar-mbid"},
|
||||
}
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(similarAgentsResp, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song Three", MBID: "mbid-3"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.FindByMBID("mbid-1", song1)
|
||||
mediaFileRepo.FindByMBID("mbid-2", song2)
|
||||
mediaFileRepo.FindByMBID("mbid-3", song3)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3))
|
||||
for _, song := range songs {
|
||||
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when artist is not found", func() {
|
||||
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
|
||||
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns songs from main artist when GetSimilarArtists returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(nil, errors.New("error getting similar artists")).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.FindByMBID("mbid-1", song1)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("returns empty list when GetArtistTopSongs returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, errors.New("error getting top songs")).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("respects count parameter", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.FindByMBID("mbid-1", song1)
|
||||
mediaFileRepo.FindByMBID("mbid-2", song2)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
||||
})
|
||||
})
|
||||
193
core/external/provider_topsongs_test.go
vendored
Normal file
193
core/external/provider_topsongs_test.go
vendored
Normal file
@@ -0,0 +1,193 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - TopSongs", func() {
|
||||
var (
|
||||
p Provider
|
||||
artistRepo *mockArtistRepo // From provider_helper_test.go
|
||||
mediaFileRepo *mockMediaFileRepo // From provider_helper_test.go
|
||||
ag *mockAgents // Consolidated mock from export_test.go
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo() // Use helper mock
|
||||
mediaFileRepo = newMockMediaFileRepo() // Use helper mock
|
||||
|
||||
// Configure tests.MockDataStore to use the testify/mock-based repos
|
||||
ds := &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
ag = new(mockAgents)
|
||||
|
||||
p = NewProvider(ds, ag)
|
||||
})
|
||||
|
||||
BeforeEach(func() {
|
||||
// Setup expectations in individual tests
|
||||
})
|
||||
|
||||
It("returns top songs for a known artist", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns nil for an unknown artist", func() {
|
||||
// Mock artist not found
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Unknown Artist", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when the agent returns an error", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent error
|
||||
agentErr := errors.New("agent error")
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(agentErr))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the agent returns ErrNotFound", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent ErrNotFound
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if count is less than available top songs", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response (only need 1 for the test)
|
||||
agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching track
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if fewer matching tracks are found", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Mock agent response
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"},
|
||||
{Name: "Song Two", MBID: "mbid-song-2"},
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks (only find song 1)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For mbid-song-2 (fails)
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For title fallback (fails)
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when context is canceled during agent call", func() {
|
||||
// Mock finding the artist
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// Setup context that will be canceled
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Mock agent call to return context canceled error
|
||||
ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once()
|
||||
|
||||
cancel() // Cancel the context before calling
|
||||
songs, err := p.TopSongs(canceledCtx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
170
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
170
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockAlbumRepo *tests.MockAlbumRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||
})
|
||||
|
||||
It("returns error when album is not found", func() {
|
||||
album, err := p.UpdateAlbumInfo(ctx, "al-not-found")
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(album).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("populates info when album exists but has no external info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-existing",
|
||||
Name: "Test Album",
|
||||
AlbumArtist: "Test Artist",
|
||||
MbzAlbumID: "mbid-album",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedInfo := &agents.AlbumInfo{
|
||||
URL: "http://example.com/album",
|
||||
Description: "Album Description",
|
||||
Images: []agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 300},
|
||||
{URL: "http://example.com/medium.jpg", Size: 200},
|
||||
{URL: "http://example.com/small.jpg", Size: 100},
|
||||
},
|
||||
}
|
||||
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(updatedAlbum.ID).To(Equal("al-existing"))
|
||||
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
|
||||
Expect(updatedAlbum.Description).To(Equal("Album Description"))
|
||||
Expect(updatedAlbum.LargeImageUrl).To(Equal("http://example.com/large.jpg"))
|
||||
Expect(updatedAlbum.MediumImageUrl).To(Equal("http://example.com/medium.jpg"))
|
||||
Expect(updatedAlbum.SmallImageUrl).To(Equal("http://example.com/small.jpg"))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when album exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-cached",
|
||||
Name: "Cached Album",
|
||||
AlbumArtist: "Cached Artist",
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-cached")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevAlbumInfoTimeToLive * 2)
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-expired",
|
||||
Name: "Expired Album",
|
||||
AlbumArtist: "Expired Artist",
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-expired")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when agent fails to get album info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-error",
|
||||
Name: "Agent Error Album",
|
||||
AlbumArtist: "Agent Error Artist",
|
||||
MbzAlbumID: "mbid-agent-error",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedErr := errors.New("agent communication failed")
|
||||
ag.On("GetAlbumInfo", ctx, "Agent Error Album", "Agent Error Artist", "mbid-agent-error").Return(nil, expectedErr)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-error")
|
||||
|
||||
Expect(err).To(MatchError(expectedErr))
|
||||
Expect(updatedAlbum).To(BeNil())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns original album when agent returns ErrNotFound", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-notfound",
|
||||
Name: "Agent NotFound Album",
|
||||
AlbumArtist: "Agent NotFound Artist",
|
||||
MbzAlbumID: "mbid-agent-notfound",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
ag.On("GetAlbumInfo", ctx, "Agent NotFound Album", "Agent NotFound Artist", "mbid-agent-notfound").Return(nil, agents.ErrNotFound)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-notfound")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil())
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockArtistRepo *tests.MockArtistRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
})
|
||||
|
||||
It("returns error when artist is not found", func() {
|
||||
artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(artist).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists")
|
||||
})
|
||||
|
||||
It("populates info when artist exists but has no external info", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-existing",
|
||||
Name: "Test Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedMBID := "mbid-artist-123"
|
||||
expectedBio := "Artist Bio"
|
||||
expectedURL := "http://artist.url"
|
||||
expectedImages := []agents.ExternalImage{
|
||||
{URL: "http://large.jpg", Size: 300},
|
||||
{URL: "http://medium.jpg", Size: 200},
|
||||
{URL: "http://small.jpg", Size: 100},
|
||||
}
|
||||
rawSimilar := []agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid-similar-1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid-similar-2"},
|
||||
{Name: "Similar Artist 3", MBID: "mbid-similar-3"},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once()
|
||||
ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||
ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-existing"))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID))
|
||||
Expect(updatedArtist.Biography).To(Equal("Artist Bio"))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg"))
|
||||
Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg"))
|
||||
Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg"))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2"))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when artist exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-cached",
|
||||
Name: "Cached Artist",
|
||||
MbzArtistID: "mbid-cached",
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl))
|
||||
Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("includes non-present similar artists when includeNotPresent is true", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
{ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(3))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw"))
|
||||
Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup"))
|
||||
})
|
||||
|
||||
It("updates ArtistInfo even if an optional agent call fails", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-agent-fail",
|
||||
Name: "Agent Fail Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedErr := errors.New("agent MBID failed")
|
||||
ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"github.com/google/wire"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
@@ -13,11 +14,12 @@ var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
GetTranscodingCache,
|
||||
NewArchiver,
|
||||
NewExternalMetadata,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
ffmpeg.New,
|
||||
scrobbler.GetPlayTracker,
|
||||
playback.GetInstance,
|
||||
|
||||
59
go.mod
59
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/navidrome/navidrome
|
||||
|
||||
go 1.24.1
|
||||
go 1.24.2
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
@@ -24,8 +24,8 @@ require (
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-chi/httprate v0.14.1
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||
github.com/go-viper/encoding/ini v0.1.1
|
||||
github.com/gohugoio/hashstructure v0.5.0
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||
@@ -37,56 +37,62 @@ require (
|
||||
github.com/kr/pretty v0.3.1
|
||||
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.24
|
||||
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.0
|
||||
github.com/onsi/gomega v1.36.2
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.37.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.24.1
|
||||
github.com/pressly/goose/v3 v3.24.2
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||
golang.org/x/image v0.25.0
|
||||
golang.org/x/net v0.37.0
|
||||
golang.org/x/sync v0.12.0
|
||||
golang.org/x/sys v0.31.0
|
||||
golang.org/x/text v0.23.0
|
||||
golang.org/x/image v0.26.0
|
||||
golang.org/x/net v0.38.0
|
||||
golang.org/x/sync v0.13.0
|
||||
golang.org/x/sys v0.32.0
|
||||
golang.org/x/text v0.24.0
|
||||
golang.org/x/time v0.11.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/cespare/reflex v0.3.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/creack/pty v1.1.11 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/jsonschema v0.12.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||
@@ -95,28 +101,37 @@ 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
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.12.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.36.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||
)
|
||||
|
||||
149
go.sum
149
go.sum
@@ -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=
|
||||
@@ -53,21 +57,21 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
|
||||
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||
@@ -85,8 +89,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro=
|
||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -102,20 +106,23 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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=
|
||||
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -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.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
|
||||
github.com/metoro-io/mcp-golang v0.11.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -160,28 +171,32 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ=
|
||||
github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
|
||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
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=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
|
||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||
@@ -194,8 +209,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
@@ -209,16 +224,16 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
|
||||
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
@@ -232,11 +247,31 @@ 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=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -248,13 +283,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -275,8 +310,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -284,8 +319,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -304,8 +339,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -326,8 +361,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -342,8 +377,8 @@ golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -355,17 +390,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
|
||||
1
mcp-server/README.md
Normal file
1
mcp-server/README.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -183,6 +183,8 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
||||
|
||||
a.Missing = true
|
||||
embedArtPath := ""
|
||||
embedArtDisc := 0
|
||||
for _, m := range mfs {
|
||||
// We assume these attributes are all the same for all songs in an album
|
||||
a.ID = m.AlbumID
|
||||
@@ -211,15 +213,15 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
comments = append(comments, m.Comment)
|
||||
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
|
||||
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
|
||||
if m.HasCoverArt && a.EmbedArtPath == "" {
|
||||
a.EmbedArtPath = m.Path
|
||||
}
|
||||
if m.DiscNumber > 0 {
|
||||
a.Discs.Add(m.DiscNumber, m.DiscSubtitle)
|
||||
}
|
||||
tags = append(tags, m.Tags.FlattenAll()...)
|
||||
a.Participants.Merge(m.Participants)
|
||||
|
||||
// Find the MediaFile with cover art and the lowest disc number to use for album cover
|
||||
embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m)
|
||||
|
||||
if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" {
|
||||
a.ExplicitStatus = "c"
|
||||
} else if m.ExplicitStatus == "e" {
|
||||
@@ -231,6 +233,7 @@ func (mfs MediaFiles) ToAlbum() Album {
|
||||
a.Missing = a.Missing && m.Missing
|
||||
}
|
||||
|
||||
a.EmbedArtPath = embedArtPath
|
||||
a.SetTags(tags)
|
||||
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
|
||||
a.Date, _ = allOrNothing(dates)
|
||||
@@ -305,6 +308,28 @@ func fixAlbumArtist(a *Album) {
|
||||
}
|
||||
}
|
||||
|
||||
// firstArtPath determines which media file path should be used for album artwork
|
||||
// based on disc number (preferring lower disc numbers) and path (for consistency)
|
||||
func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) {
|
||||
if !m.HasCoverArt {
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
// If current has no disc number (currentDisc == 0) or new file has lower disc number
|
||||
if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
|
||||
// If disc numbers are equal, use path for ordering
|
||||
if m.DiscNumber == currentDisc {
|
||||
if m.Path < currentPath || currentPath == "" {
|
||||
return m.Path, m.DiscNumber
|
||||
}
|
||||
}
|
||||
|
||||
return currentPath, currentDisc
|
||||
}
|
||||
|
||||
type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
|
||||
type MediaFileRepository interface {
|
||||
|
||||
@@ -305,6 +305,101 @@ var _ = Describe("MediaFiles", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
Context("Album Art", func() {
|
||||
When("we have media files with cover art from multiple discs", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc2/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc3/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 3,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art from the lowest disc number", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with cover art from the same disc number", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc1/02.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art with the lowest path alphabetically", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with some missing cover art", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/Disc1/01.mp3",
|
||||
HasCoverArt: false,
|
||||
DiscNumber: 1,
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/Disc2/01.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the file with cover art even if from a higher disc number", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc2/01.mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
When("we have media files with path names that don't correlate with disc numbers", func() {
|
||||
BeforeEach(func() {
|
||||
mfs = MediaFiles{
|
||||
{
|
||||
Path: "Artist/Album/file-z.mp3", // Path would be sorted last alphabetically
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 1, // But it has lowest disc number
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/file-a.mp3", // Path would be sorted first alphabetically
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 2, // But it has higher disc number
|
||||
},
|
||||
{
|
||||
Path: "Artist/Album/file-m.mp3",
|
||||
HasCoverArt: true,
|
||||
DiscNumber: 3,
|
||||
},
|
||||
}
|
||||
})
|
||||
It("selects the cover art from the lowest disc number regardless of path", func() {
|
||||
album := mfs.ToAlbum()
|
||||
Expect(album.EmbedArtPath).To(Equal("Artist/Album/file-z.mp3"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
|
||||
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
|
||||
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
|
||||
mf.MbzAlbumType = md.String(model.TagReleaseType)
|
||||
|
||||
// ReplayGain
|
||||
mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} ennå.",
|
||||
"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 gå etter siste side",
|
||||
"page_out_from_begin": "Kan ikke gå 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"tags": "Дополнительные теги",
|
||||
"mappedTags": "Сопоставленные теги",
|
||||
"rawTags": "Исходные теги",
|
||||
"bitDepth": "Битовая глубина"
|
||||
"bitDepth": "Битовая глубина",
|
||||
"sampleRate": "Частота дискретизации (Гц)"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@@ -72,7 +73,7 @@
|
||||
"grouping": "Группирование",
|
||||
"media": "Медиа",
|
||||
"mood": "Настроение",
|
||||
"date": ""
|
||||
"date": "Дата записи"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Играть",
|
||||
|
||||
@@ -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": "Појачај"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "添加/移除星标"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -30,37 +31,37 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
externalMetadata core.ExternalMetadata
|
||||
playlists core.Playlists
|
||||
scanner scanner.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists core.Playlists
|
||||
scanner scanner.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, externalMetadata core.ExternalMetadata, scanner scanner.Scanner, broker events.Broker,
|
||||
players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
|
||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
externalMetadata: externalMetadata,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
provider: provider,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
|
||||
@@ -210,7 +210,7 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id)
|
||||
album, err := api.provider.UpdateAlbumInfo(ctx, id)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -278,7 +278,7 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m
|
||||
count := p.IntOr("count", 20)
|
||||
includeNotPresent := p.BoolOr("includeNotPresent", false)
|
||||
|
||||
artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent)
|
||||
artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error)
|
||||
}
|
||||
count := p.IntOr("count", 50)
|
||||
|
||||
songs, err := api.externalMetadata.SimilarSongs(ctx, id, count)
|
||||
songs, err := api.provider.SimilarSongs(ctx, id, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -377,8 +377,8 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
count := p.IntOr("count", 50)
|
||||
|
||||
songs, err := api.externalMetadata.TopSongs(ctx, artist, count)
|
||||
if err != nil {
|
||||
songs, err := api.provider.TopSongs(ctx, artist, count)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -10,56 +10,56 @@ import (
|
||||
|
||||
func CreateMockAlbumRepo() *MockAlbumRepo {
|
||||
return &MockAlbumRepo{
|
||||
data: make(map[string]*model.Album),
|
||||
Data: make(map[string]*model.Album),
|
||||
}
|
||||
}
|
||||
|
||||
type MockAlbumRepo struct {
|
||||
model.AlbumRepository
|
||||
data map[string]*model.Album
|
||||
all model.Albums
|
||||
err bool
|
||||
Data map[string]*model.Album
|
||||
All model.Albums
|
||||
Err bool
|
||||
Options model.QueryOptions
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) SetError(err bool) {
|
||||
m.err = err
|
||||
m.Err = err
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) SetData(albums model.Albums) {
|
||||
m.data = make(map[string]*model.Album, len(albums))
|
||||
m.all = albums
|
||||
for i, a := range m.all {
|
||||
m.data[a.ID] = &m.all[i]
|
||||
m.Data = make(map[string]*model.Album, len(albums))
|
||||
m.All = albums
|
||||
for i, a := range m.All {
|
||||
m.Data[a.ID] = &m.All[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return false, errors.New("unexpected error")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
_, found := m.Data[id]
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("unexpected error")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) Put(al *model.Album) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("unexpected error")
|
||||
}
|
||||
if al.ID == "" {
|
||||
al.ID = id.NewRandom()
|
||||
}
|
||||
m.data[al.ID] = al
|
||||
m.Data[al.ID] = al
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -67,17 +67,17 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) {
|
||||
if len(qo) > 0 {
|
||||
m.Options = qo[0]
|
||||
}
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("unexpected error")
|
||||
}
|
||||
return m.all, nil
|
||||
return m.All, nil
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("unexpected error")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
d.PlayCount++
|
||||
d.PlayDate = ×tamp
|
||||
return nil
|
||||
@@ -85,15 +85,15 @@ func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) {
|
||||
return int64(len(m.all)), nil
|
||||
return int64(len(m.All)), nil
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("unexpected error")
|
||||
}
|
||||
return func(yield func(model.Album, error) bool) {
|
||||
for _, a := range m.data {
|
||||
for _, a := range m.Data {
|
||||
if a.ID == "error" {
|
||||
if !yield(*a, errors.New("error")) {
|
||||
break
|
||||
@@ -110,4 +110,11 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
|
||||
if m.Err {
|
||||
return errors.New("unexpected error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)
|
||||
|
||||
@@ -10,61 +10,61 @@ import (
|
||||
|
||||
func CreateMockArtistRepo() *MockArtistRepo {
|
||||
return &MockArtistRepo{
|
||||
data: make(map[string]*model.Artist),
|
||||
Data: make(map[string]*model.Artist),
|
||||
}
|
||||
}
|
||||
|
||||
type MockArtistRepo struct {
|
||||
model.ArtistRepository
|
||||
data map[string]*model.Artist
|
||||
err bool
|
||||
Data map[string]*model.Artist
|
||||
Err bool
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) SetError(err bool) {
|
||||
m.err = err
|
||||
m.Err = err
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) SetData(artists model.Artists) {
|
||||
m.data = make(map[string]*model.Artist)
|
||||
m.Data = make(map[string]*model.Artist)
|
||||
for i, a := range artists {
|
||||
m.data[a.ID] = &artists[i]
|
||||
m.Data[a.ID] = &artists[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return false, errors.New("Error!")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
_, found := m.Data[id]
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if ar.ID == "" {
|
||||
ar.ID = id.NewRandom()
|
||||
}
|
||||
m.data[ar.ID] = ar
|
||||
m.Data[ar.ID] = ar
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
d.PlayCount++
|
||||
d.PlayDate = ×tamp
|
||||
return nil
|
||||
@@ -72,4 +72,26 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("mock repo error")
|
||||
}
|
||||
var allArtists model.Artists
|
||||
for _, artist := range m.Data {
|
||||
allArtists = append(allArtists, *artist)
|
||||
}
|
||||
// Apply Max=1 if present (simple simulation for findArtistByName)
|
||||
if len(options) > 0 && options[0].Max == 1 && len(allArtists) > 0 {
|
||||
return allArtists[:1], nil
|
||||
}
|
||||
return allArtists, nil
|
||||
}
|
||||
|
||||
func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error {
|
||||
if m.Err {
|
||||
return errors.New("mock repo error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.ArtistRepository = (*MockArtistRepo)(nil)
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
|
||||
type MockedGenreRepo struct {
|
||||
Error error
|
||||
data map[string]model.Genre
|
||||
Data map[string]model.Genre
|
||||
}
|
||||
|
||||
func (r *MockedGenreRepo) init() {
|
||||
if r.data == nil {
|
||||
r.data = make(map[string]model.Genre)
|
||||
if r.Data == nil {
|
||||
r.Data = make(map[string]model.Genre)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) {
|
||||
r.init()
|
||||
|
||||
var all model.Genres
|
||||
for _, g := range r.data {
|
||||
for _, g := range r.Data {
|
||||
all = append(all, g)
|
||||
}
|
||||
return all, nil
|
||||
@@ -33,6 +33,6 @@ func (r *MockedGenreRepo) Put(g *model.Genre) error {
|
||||
return r.Error
|
||||
}
|
||||
r.init()
|
||||
r.data[g.ID] = *g
|
||||
r.Data[g.ID] = *g
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
|
||||
type MockLibraryRepo struct {
|
||||
model.LibraryRepository
|
||||
data map[int]model.Library
|
||||
Data map[int]model.Library
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockLibraryRepo) SetData(data model.Libraries) {
|
||||
m.data = make(map[int]model.Library)
|
||||
m.Data = make(map[int]model.Library)
|
||||
for _, d := range data {
|
||||
m.data[d.ID] = d
|
||||
m.Data[d.ID] = d
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error)
|
||||
if m.Err != nil {
|
||||
return nil, m.Err
|
||||
}
|
||||
return maps.Values(m.data), nil
|
||||
return maps.Values(m.Data), nil
|
||||
}
|
||||
|
||||
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
|
||||
if m.Err != nil {
|
||||
return "", m.Err
|
||||
}
|
||||
if lib, ok := m.data[id]; ok {
|
||||
if lib, ok := m.Data[id]; ok {
|
||||
return lib.Path, nil
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
|
||||
@@ -14,40 +14,40 @@ import (
|
||||
|
||||
func CreateMockMediaFileRepo() *MockMediaFileRepo {
|
||||
return &MockMediaFileRepo{
|
||||
data: make(map[string]*model.MediaFile),
|
||||
Data: make(map[string]*model.MediaFile),
|
||||
}
|
||||
}
|
||||
|
||||
type MockMediaFileRepo struct {
|
||||
model.MediaFileRepository
|
||||
data map[string]*model.MediaFile
|
||||
err bool
|
||||
Data map[string]*model.MediaFile
|
||||
Err bool
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||
m.err = err
|
||||
m.Err = err
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) {
|
||||
m.data = make(map[string]*model.MediaFile)
|
||||
m.Data = make(map[string]*model.MediaFile)
|
||||
for i, mf := range mfs {
|
||||
m.data[mf.ID] = &mfs[i]
|
||||
m.Data[mf.ID] = &mfs[i]
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return false, errors.New("error")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
_, found := m.Data[id]
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
// Intentionally clone the file and remove participants. This should
|
||||
// catch any caller that actually means to call GetWithParticipants
|
||||
res := *d
|
||||
@@ -58,52 +58,52 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
values := slices.Collect(maps.Values(m.data))
|
||||
values := slices.Collect(maps.Values(m.Data))
|
||||
return slice.Map(values, func(p *model.MediaFile) model.MediaFile {
|
||||
return *p
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if mf.ID == "" {
|
||||
mf.ID = id.NewRandom()
|
||||
}
|
||||
m.data[mf.ID] = mf
|
||||
m.Data[mf.ID] = mf
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) Delete(id string) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if _, ok := m.data[id]; !ok {
|
||||
if _, ok := m.Data[id]; !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
delete(m.data, id)
|
||||
delete(m.Data, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
d.PlayCount++
|
||||
d.PlayDate = ×tamp
|
||||
return nil
|
||||
@@ -112,12 +112,12 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var res = make(model.MediaFiles, len(m.data))
|
||||
var res = make(model.MediaFiles, len(m.Data))
|
||||
i := 0
|
||||
for _, a := range m.data {
|
||||
for _, a := range m.Data {
|
||||
if a.AlbumID == artistId {
|
||||
res[i] = *a
|
||||
i++
|
||||
@@ -128,17 +128,17 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var res model.MediaFiles
|
||||
for _, a := range m.data {
|
||||
for _, a := range m.Data {
|
||||
if a.LibraryID == libId && a.Missing {
|
||||
res = append(res, *a)
|
||||
}
|
||||
}
|
||||
|
||||
for _, a := range m.data {
|
||||
for _, a := range m.Data {
|
||||
if a.LibraryID == libId && !(*a).Missing && slices.IndexFunc(res, func(mediaFile model.MediaFile) bool {
|
||||
return mediaFile.PID == a.PID
|
||||
}) != -1 {
|
||||
|
||||
@@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model"
|
||||
type MockedPropertyRepo struct {
|
||||
model.PropertyRepository
|
||||
Error error
|
||||
data map[string]string
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
func (p *MockedPropertyRepo) init() {
|
||||
if p.data == nil {
|
||||
p.data = make(map[string]string)
|
||||
if p.Data == nil {
|
||||
p.Data = make(map[string]string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func (p *MockedPropertyRepo) Put(id string, value string) error {
|
||||
return p.Error
|
||||
}
|
||||
p.init()
|
||||
p.data[id] = value
|
||||
p.Data[id] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (p *MockedPropertyRepo) Get(id string) (string, error) {
|
||||
return "", p.Error
|
||||
}
|
||||
p.init()
|
||||
if v, ok := p.data[id]; ok {
|
||||
if v, ok := p.Data[id]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
@@ -39,8 +39,8 @@ func (p *MockedPropertyRepo) Delete(id string) error {
|
||||
return p.Error
|
||||
}
|
||||
p.init()
|
||||
if _, ok := p.data[id]; ok {
|
||||
delete(p.data, id)
|
||||
if _, ok := p.Data[id]; ok {
|
||||
delete(p.Data, id)
|
||||
return nil
|
||||
}
|
||||
return model.ErrNotFound
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
|
||||
type MockedRadioRepo struct {
|
||||
model.RadioRepository
|
||||
data map[string]*model.Radio
|
||||
all model.Radios
|
||||
err bool
|
||||
Data map[string]*model.Radio
|
||||
All model.Radios
|
||||
Err bool
|
||||
Options model.QueryOptions
|
||||
}
|
||||
|
||||
@@ -20,44 +20,44 @@ func CreateMockedRadioRepo() *MockedRadioRepo {
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) SetError(err bool) {
|
||||
m.err = err
|
||||
m.Err = err
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return 0, errors.New("error")
|
||||
}
|
||||
return int64(len(m.data)), nil
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) Delete(id string) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("Error!")
|
||||
}
|
||||
|
||||
_, found := m.data[id]
|
||||
_, found := m.Data[id]
|
||||
|
||||
if !found {
|
||||
return errors.New("not found")
|
||||
}
|
||||
|
||||
delete(m.data, id)
|
||||
delete(m.Data, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) Exists(id string) (bool, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return false, errors.New("Error!")
|
||||
}
|
||||
_, found := m.data[id]
|
||||
_, found := m.Data[id]
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
if d, ok := m.data[id]; ok {
|
||||
if d, ok := m.Data[id]; ok {
|
||||
return d, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
@@ -67,19 +67,19 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error)
|
||||
if len(qo) > 0 {
|
||||
m.Options = qo[0]
|
||||
}
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return nil, errors.New("Error!")
|
||||
}
|
||||
return m.all, nil
|
||||
return m.All, nil
|
||||
}
|
||||
|
||||
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
|
||||
if m.err {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if radio.ID == "" {
|
||||
radio.ID = id.NewRandom()
|
||||
}
|
||||
m.data[radio.ID] = radio
|
||||
m.Data[radio.ID] = radio
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
type MockedScrobbleBufferRepo struct {
|
||||
Error error
|
||||
data model.ScrobbleEntries
|
||||
Data model.ScrobbleEntries
|
||||
}
|
||||
|
||||
func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo {
|
||||
@@ -20,7 +20,7 @@ func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) {
|
||||
return nil, m.Error
|
||||
}
|
||||
userIds := make(map[string]struct{})
|
||||
for _, e := range m.data {
|
||||
for _, e := range m.Data {
|
||||
if e.Service == service {
|
||||
userIds[e.UserID] = struct{}{}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string,
|
||||
if m.Error != nil {
|
||||
return m.Error
|
||||
}
|
||||
m.data = append(m.data, model.ScrobbleEntry{
|
||||
m.Data = append(m.Data, model.ScrobbleEntry{
|
||||
MediaFile: model.MediaFile{ID: mediaFileId},
|
||||
Service: service,
|
||||
UserID: userId,
|
||||
@@ -50,7 +50,7 @@ func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.Scrobble
|
||||
if m.Error != nil {
|
||||
return nil, m.Error
|
||||
}
|
||||
for _, e := range m.data {
|
||||
for _, e := range m.Data {
|
||||
if e.Service == service && e.UserID == userId {
|
||||
return &e, nil
|
||||
}
|
||||
@@ -63,13 +63,13 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error {
|
||||
return m.Error
|
||||
}
|
||||
newData := model.ScrobbleEntries{}
|
||||
for _, e := range m.data {
|
||||
for _, e := range m.Data {
|
||||
if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID {
|
||||
continue
|
||||
}
|
||||
newData = append(newData, e)
|
||||
}
|
||||
m.data = newData
|
||||
m.Data = newData
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -77,5 +77,5 @@ func (m *MockedScrobbleBufferRepo) Length() (int64, error) {
|
||||
if m.Error != nil {
|
||||
return 0, m.Error
|
||||
}
|
||||
return int64(len(m.data)), nil
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model"
|
||||
type MockedUserPropsRepo struct {
|
||||
model.UserPropsRepository
|
||||
Error error
|
||||
data map[string]string
|
||||
Data map[string]string
|
||||
}
|
||||
|
||||
func (p *MockedUserPropsRepo) init() {
|
||||
if p.data == nil {
|
||||
p.data = make(map[string]string)
|
||||
if p.Data == nil {
|
||||
p.Data = make(map[string]string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func (p *MockedUserPropsRepo) Put(userId, key string, value string) error {
|
||||
return p.Error
|
||||
}
|
||||
p.init()
|
||||
p.data[userId+key] = value
|
||||
p.Data[userId+key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) {
|
||||
return "", p.Error
|
||||
}
|
||||
p.init()
|
||||
if v, ok := p.data[userId+key]; ok {
|
||||
if v, ok := p.Data[userId+key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", model.ErrNotFound
|
||||
@@ -39,8 +39,8 @@ func (p *MockedUserPropsRepo) Delete(userId, key string) error {
|
||||
return p.Error
|
||||
}
|
||||
p.init()
|
||||
if _, ok := p.data[userId+key]; ok {
|
||||
delete(p.data, userId+key)
|
||||
if _, ok := p.Data[userId+key]; ok {
|
||||
delete(p.Data, userId+key)
|
||||
return nil
|
||||
}
|
||||
return model.ErrNotFound
|
||||
|
||||
@@ -124,6 +124,14 @@ const AlbumSongs = (props) => {
|
||||
size: isDesktop && <SizeField source="size" sortable={false} />,
|
||||
channels: isDesktop && <NumberField source="channels" sortable={false} />,
|
||||
bpm: isDesktop && <NumberField source="bpm" sortable={false} />,
|
||||
genre: <TextField source="genre" sortable={false} />,
|
||||
mood: isDesktop && (
|
||||
<FunctionField
|
||||
source="mood"
|
||||
render={(r) => r.tags?.mood?.[0] ?? ''}
|
||||
sortable={false}
|
||||
/>
|
||||
),
|
||||
rating: isDesktop && config.enableStarRating && (
|
||||
<RatingField
|
||||
resource={'song'}
|
||||
@@ -139,7 +147,16 @@ const AlbumSongs = (props) => {
|
||||
resource: 'albumSong',
|
||||
columns: toggleableFields,
|
||||
omittedColumns: ['title'],
|
||||
defaultOff: ['channels', 'bpm', 'year', 'playCount', 'playDate', 'size'],
|
||||
defaultOff: [
|
||||
'channels',
|
||||
'bpm',
|
||||
'year',
|
||||
'playCount',
|
||||
'playDate',
|
||||
'size',
|
||||
'mood',
|
||||
'genre',
|
||||
],
|
||||
})
|
||||
|
||||
const bulkActionsLabel = isDesktop
|
||||
@@ -168,7 +185,6 @@ const AlbumSongs = (props) => {
|
||||
{...props}
|
||||
hasBulkActions={true}
|
||||
showDiscSubtitles={true}
|
||||
showReleaseDivider={true}
|
||||
contextAlwaysVisible={!isDesktop}
|
||||
classes={{ row: classes.row }}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"updatedAt": "Updated at",
|
||||
"bitRate": "Bit rate",
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"channels": "Channels",
|
||||
"discSubtitle": "Disc Subtitle",
|
||||
"starred": "Favourite",
|
||||
|
||||
Reference in New Issue
Block a user