mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into integrate-lbz-capabilities
This commit is contained in:
commit
ffd9167d3d
4
.github/workflows/pipeline.yml
vendored
4
.github/workflows/pipeline.yml
vendored
@ -14,7 +14,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CROSS_TAGLIB_VERSION: "2.1.1-1"
|
||||
CROSS_TAGLIB_VERSION: "2.1.1-2"
|
||||
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||
|
||||
@ -193,7 +193,7 @@ jobs:
|
||||
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
||||
|
||||
@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
### Get TagLib
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||
ARG TARGETPLATFORM
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-1
|
||||
ARG CROSS_TAGLIB_VERSION=2.1.1-2
|
||||
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||
|
||||
# wget in busybox can't follow redirects
|
||||
@ -63,7 +63,7 @@ COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
||||
4
Makefile
4
Makefile
@ -13,13 +13,13 @@ GIT_SHA=source_archive
|
||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||
endif
|
||||
|
||||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||
GOLANGCI_LINT_VERSION ?= v2.8.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
@ -29,14 +29,12 @@ type httpDoer interface {
|
||||
|
||||
type client struct {
|
||||
httpDoer httpDoer
|
||||
language string
|
||||
jwt jwtToken
|
||||
}
|
||||
|
||||
func newClient(hc httpDoer, language string) *client {
|
||||
func newClient(hc httpDoer) *client {
|
||||
return &client{
|
||||
httpDoer: hc,
|
||||
language: language,
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +127,7 @@ const pipeAPIURL = "https://pipe.deezer.com/api"
|
||||
|
||||
var strictPolicy = bluemonday.StrictPolicy()
|
||||
|
||||
func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
|
||||
func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
|
||||
jwt, err := c.getJWT(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
|
||||
@ -160,10 +158,10 @@ func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept-Language", c.language)
|
||||
req.Header.Set("Accept-Language", lang)
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
|
||||
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
|
||||
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
|
||||
resp, err := c.httpDoer.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@ -21,7 +21,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient(httpClient, "en")
|
||||
client = newClient(httpClient)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ var _ = Describe("client", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &fakeHttpClient{}
|
||||
client = newClient(httpClient, "en")
|
||||
client = newClient(httpClient)
|
||||
})
|
||||
|
||||
Describe("ArtistImages", func() {
|
||||
@ -78,40 +78,33 @@ var _ = Describe("client", func() {
|
||||
})
|
||||
|
||||
It("returns artist bio from a successful request", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
bio, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
||||
Expect(bio).ToNot(ContainSubstring("<p>"))
|
||||
Expect(bio).ToNot(ContainSubstring("</p>"))
|
||||
})
|
||||
|
||||
It("uses the configured language", func() {
|
||||
client = newClient(httpClient, "fr")
|
||||
// Mock JWT token for the new client instance with a valid JWT
|
||||
testJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
||||
})
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
||||
It("uses the provided language", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
_, err = client.getArtistBio(GinkgoT().Context(), 27)
|
||||
_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
|
||||
})
|
||||
|
||||
It("includes the JWT token in the request", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
_, err = client.getArtistBio(GinkgoT().Context(), 27)
|
||||
_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||
Expect(err).To(BeNil())
|
||||
// Verify that the Authorization header has the Bearer token format
|
||||
authHeader := httpClient.lastRequest.Header.Get("Authorization")
|
||||
@ -142,7 +135,7 @@ var _ = Describe("client", func() {
|
||||
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 999)
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
|
||||
Expect(err.Error()).To(ContainSubstring("Artist not found"))
|
||||
@ -164,7 +157,7 @@ var _ = Describe("client", func() {
|
||||
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||
Expect(err).To(MatchError("deezer: biography not found"))
|
||||
})
|
||||
|
||||
@ -174,7 +167,7 @@ var _ = Describe("client", func() {
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
|
||||
})
|
||||
@ -187,7 +180,7 @@ var _ = Describe("client", func() {
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
|
||||
})
|
||||
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27)
|
||||
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
|
||||
})
|
||||
|
||||
@ -26,15 +26,19 @@ const deezerArtistSearchLimit = 50
|
||||
type deezerAgent struct {
|
||||
dataStore model.DataStore
|
||||
client *client
|
||||
languages []string
|
||||
}
|
||||
|
||||
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||
agent := &deezerAgent{dataStore: dataStore}
|
||||
agent := &deezerAgent{
|
||||
dataStore: dataStore,
|
||||
languages: conf.Server.Deezer.Languages,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||
agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
|
||||
agent.client = newClient(cachedHttpClient)
|
||||
return agent
|
||||
}
|
||||
|
||||
@ -149,7 +153,14 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.client.getArtistBio(ctx, artist.ID)
|
||||
for _, lang := range s.languages {
|
||||
bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
|
||||
if err == nil && bio != "" {
|
||||
return bio, nil
|
||||
}
|
||||
log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
|
||||
}
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
171
adapters/deezer/deezer_test.go
Normal file
171
adapters/deezer/deezer_test.go
Normal file
@ -0,0 +1,171 @@
|
||||
package deezer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("deezerAgent", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Deezer.Enabled = true
|
||||
})
|
||||
|
||||
Describe("deezerConstructor", func() {
|
||||
It("uses configured languages", func() {
|
||||
conf.Server.Deezer.Languages = []string{"pt", "en"}
|
||||
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistBiography - Language Fallback", func() {
|
||||
var agent *deezerAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = newLangAwareHttpClient()
|
||||
|
||||
// Mock search artist (returns Michael Jackson)
|
||||
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
|
||||
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
|
||||
|
||||
// Mock JWT token
|
||||
testJWT := createTestJWT(5 * time.Minute)
|
||||
httpClient.jwtResponse = &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
||||
}
|
||||
})
|
||||
|
||||
setupAgent := func(languages []string) {
|
||||
conf.Server.Deezer.Languages = languages
|
||||
agent = &deezerAgent{
|
||||
dataStore: &tests.MockDataStore{},
|
||||
client: newClient(httpClient),
|
||||
languages: languages,
|
||||
}
|
||||
}
|
||||
|
||||
It("returns content in first language when available (1 bio API call)", func() {
|
||||
setupAgent([]string{"fr", "en"})
|
||||
|
||||
// French biography available
|
||||
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
|
||||
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
|
||||
Expect(httpClient.bioRequestCount).To(Equal(1))
|
||||
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
|
||||
})
|
||||
|
||||
It("falls back to second language when first returns empty (2 bio API calls)", func() {
|
||||
setupAgent([]string{"ja", "en"})
|
||||
|
||||
// Japanese returns empty biography
|
||||
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
||||
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
|
||||
// English returns full biography
|
||||
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
||||
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
||||
Expect(httpClient.bioRequestCount).To(Equal(2))
|
||||
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
|
||||
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when all languages return empty", func() {
|
||||
setupAgent([]string{"ja", "xx"})
|
||||
|
||||
// Both languages return empty biography
|
||||
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
||||
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
|
||||
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
||||
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(httpClient.bioRequestCount).To(Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
|
||||
type langAwareHttpClient struct {
|
||||
searchResponse *http.Response
|
||||
jwtResponse *http.Response
|
||||
bioResponses map[string]*http.Response
|
||||
bioRequests []*http.Request
|
||||
bioRequestCount int
|
||||
}
|
||||
|
||||
func newLangAwareHttpClient() *langAwareHttpClient {
|
||||
return &langAwareHttpClient{
|
||||
bioResponses: make(map[string]*http.Response),
|
||||
bioRequests: make([]*http.Request, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
// Handle search artist request
|
||||
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
|
||||
if c.searchResponse != nil {
|
||||
return c.searchResponse, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle JWT token request
|
||||
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
|
||||
if c.jwtResponse != nil {
|
||||
return c.jwtResponse, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Handle bio request (GraphQL API)
|
||||
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
|
||||
c.bioRequestCount++
|
||||
c.bioRequests = append(c.bioRequests, req)
|
||||
lang := req.Header.Get("Accept-Language")
|
||||
if resp, ok := c.bioResponses[lang]; ok {
|
||||
return resp, nil
|
||||
}
|
||||
// Return empty bio by default
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
panic("URL not mocked: " + req.URL.String())
|
||||
}
|
||||
@ -6,7 +6,7 @@
|
||||
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
|
||||
// through a single file open operation.
|
||||
//
|
||||
// This extractor is registered under the name "gotaglib". It only works with a filesystem
|
||||
// This extractor is registered under the name "taglib". It only works with a filesystem
|
||||
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
|
||||
// must implement io.ReadSeeker for go-taglib to read them.
|
||||
package gotaglib
|
||||
|
||||
@ -26,8 +26,8 @@ const (
|
||||
sessionKeyProperty = "LastFMSessionKey"
|
||||
)
|
||||
|
||||
var ignoredBiographies = []string{
|
||||
// Unknown Artist
|
||||
var ignoredContent = []string{
|
||||
// Empty Artist/Album
|
||||
`<a href="https://www.last.fm/music/`,
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ type lastfmAgent struct {
|
||||
sessionKeys *agents.SessionKeys
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
languages []string
|
||||
client *client
|
||||
httpClient httpDoer
|
||||
getInfoMutex sync.Mutex
|
||||
@ -48,7 +48,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
}
|
||||
l := &lastfmAgent{
|
||||
ds: ds,
|
||||
lang: conf.Server.LastFM.Language,
|
||||
languages: conf.Server.LastFM.Languages,
|
||||
apiKey: conf.Server.LastFM.ApiKey,
|
||||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
@ -58,7 +58,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.httpClient = chc
|
||||
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
|
||||
l.client = newClient(l.apiKey, l.secret, chc)
|
||||
return l
|
||||
}
|
||||
|
||||
@ -68,22 +68,47 @@ func (l *lastfmAgent) AgentName() string {
|
||||
|
||||
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
|
||||
|
||||
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// isValidContent checks if content is non-empty and not in the ignored list
|
||||
func isValidContent(content string) bool {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
for _, ign := range ignoredContent {
|
||||
if strings.HasPrefix(content, ign) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return &agents.AlbumInfo{
|
||||
Name: a.Name,
|
||||
MBID: a.MBID,
|
||||
Description: a.Description.Summary,
|
||||
URL: a.URL,
|
||||
}, nil
|
||||
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
var a *Album
|
||||
var resp agents.AlbumInfo
|
||||
for _, lang := range l.languages {
|
||||
var err error
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Name = a.Name
|
||||
resp.MBID = a.MBID
|
||||
resp.URL = a.URL
|
||||
if isValidContent(a.Description.Summary) {
|
||||
resp.Description = strings.TrimSpace(a.Description.Summary)
|
||||
return &resp, nil
|
||||
}
|
||||
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
|
||||
}
|
||||
// This condition should not be hit (languages default to ["en"]), but just in case
|
||||
if a == nil {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid)
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -118,7 +143,7 @@ func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid str
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -129,7 +154,7 @@ func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string)
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -140,20 +165,17 @@ func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.Bio.Summary = strings.TrimSpace(a.Bio.Summary)
|
||||
if a.Bio.Summary == "" {
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
for _, ign := range ignoredBiographies {
|
||||
if strings.HasPrefix(a.Bio.Summary, ign) {
|
||||
return "", nil
|
||||
for _, lang := range l.languages {
|
||||
a, err := l.callArtistGetInfo(ctx, name, lang)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isValidContent(a.Bio.Summary) {
|
||||
return strings.TrimSpace(a.Bio.Summary), nil
|
||||
}
|
||||
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
|
||||
}
|
||||
return a.Bio.Summary, nil
|
||||
return "", agents.ErrNotFound
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
@ -219,7 +241,7 @@ var (
|
||||
|
||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
|
||||
a, err := l.callArtistGetInfo(ctx, name)
|
||||
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get artist info: %w", err)
|
||||
}
|
||||
@ -259,14 +281,14 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, mbid)
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
|
||||
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
||||
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||
return l.callAlbumGetInfo(ctx, name, artist, "")
|
||||
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@ -280,11 +302,11 @@ func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid s
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) {
|
||||
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||
l.getInfoMutex.Lock()
|
||||
defer l.getInfoMutex.Unlock()
|
||||
|
||||
a, err := l.client.artistGetInfo(ctx, name)
|
||||
a, err := l.client.artistGetInfo(ctx, name, lang)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||
return nil, err
|
||||
|
||||
@ -39,12 +39,12 @@ var _ = Describe("lastfmAgent", func() {
|
||||
})
|
||||
Describe("lastFMConstructor", func() {
|
||||
When("Agent is properly configured", func() {
|
||||
It("uses configured api key and language", func() {
|
||||
conf.Server.LastFM.Language = "pt"
|
||||
It("uses configured api key and languages", func() {
|
||||
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
||||
agent := lastFMConstructor(ds)
|
||||
Expect(agent.apiKey).To(Equal("123"))
|
||||
Expect(agent.secret).To(Equal("secret"))
|
||||
Expect(agent.lang).To(Equal("pt"))
|
||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
||||
})
|
||||
})
|
||||
When("Agent is disabled", func() {
|
||||
@ -72,7 +72,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
@ -102,12 +102,129 @@ var _ = Describe("lastfmAgent", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Language Fallback", func() {
|
||||
Describe("GetArtistBiography", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = newLangAwareHttpClient()
|
||||
})
|
||||
|
||||
It("returns content in first language when available (1 API call)", func() {
|
||||
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||
|
||||
// Portuguese biography available
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
|
||||
Expect(httpClient.requestCount).To(Equal(1))
|
||||
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
|
||||
})
|
||||
|
||||
It("falls back to second language when first returns empty (2 API calls)", func() {
|
||||
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||
|
||||
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
|
||||
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
||||
// English returns full biography
|
||||
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
|
||||
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
|
||||
|
||||
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
|
||||
Expect(httpClient.requestCount).To(Equal(2))
|
||||
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
|
||||
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when all languages return empty", func() {
|
||||
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||
|
||||
// Both languages return empty/ignored biography (using actual Last.fm response format)
|
||||
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
||||
// Second language also returns empty
|
||||
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
|
||||
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
|
||||
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(httpClient.requestCount).To(Equal(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAlbumInfo", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *langAwareHttpClient
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = newLangAwareHttpClient()
|
||||
})
|
||||
|
||||
It("falls back to second language when first returns empty description (2 API calls)", func() {
|
||||
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||
|
||||
// Japanese returns album without wiki/description (actual Last.fm response)
|
||||
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
||||
// English returns album with description
|
||||
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
|
||||
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
|
||||
|
||||
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albumInfo.Name).To(Equal("Dois"))
|
||||
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
|
||||
Expect(httpClient.requestCount).To(Equal(2))
|
||||
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
|
||||
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
|
||||
})
|
||||
|
||||
It("returns album without description when all languages return empty", func() {
|
||||
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||
|
||||
// Both languages return album without description
|
||||
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
||||
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
|
||||
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
|
||||
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
|
||||
|
||||
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(albumInfo.Name).To(Equal("Dois"))
|
||||
Expect(albumInfo.Description).To(BeEmpty())
|
||||
Expect(httpClient.requestCount).To(Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarArtists", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
@ -145,7 +262,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
@ -183,7 +300,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
@ -233,7 +350,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "en", httpClient)
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
track = &model.MediaFile{
|
||||
@ -407,7 +524,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
@ -477,7 +594,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
BeforeEach(func() {
|
||||
apiClient = &tests.FakeHttpClient{}
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", "pt", apiClient)
|
||||
client := newClient("API_KEY", "SECRET", apiClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
agent.httpClient = httpClient
|
||||
@ -538,3 +655,31 @@ var _ = Describe("lastfmAgent", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
|
||||
type langAwareHttpClient struct {
|
||||
responses map[string]http.Response
|
||||
requests []*http.Request
|
||||
requestCount int
|
||||
}
|
||||
|
||||
func newLangAwareHttpClient() *langAwareHttpClient {
|
||||
return &langAwareHttpClient{
|
||||
responses: make(map[string]http.Response),
|
||||
requests: make([]*http.Request, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
c.requestCount++
|
||||
c.requests = append(c.requests, req)
|
||||
lang := req.URL.Query().Get("lang")
|
||||
if resp, ok := c.responses[lang]; ok {
|
||||
return &resp, nil
|
||||
}
|
||||
// Return default empty response if no specific response is configured
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -44,7 +44,7 @@ func NewRouter(ds model.DataStore) *Router {
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(r.apiKey, r.secret, "en", hc)
|
||||
r.client = newClient(r.apiKey, r.secret, hc)
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@ -34,24 +34,23 @@ type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func newClient(apiKey string, secret string, lang string, hc httpDoer) *client {
|
||||
return &client{apiKey, secret, lang, hc}
|
||||
func newClient(apiKey string, secret string, hc httpDoer) *client {
|
||||
return &client{apiKey, secret, hc}
|
||||
}
|
||||
|
||||
type client struct {
|
||||
apiKey string
|
||||
secret string
|
||||
lang string
|
||||
hc httpDoer
|
||||
}
|
||||
|
||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) {
|
||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "album.getInfo")
|
||||
params.Add("album", name)
|
||||
params.Add("artist", artist)
|
||||
params.Add("mbid", mbid)
|
||||
params.Add("lang", c.lang)
|
||||
params.Add("lang", lang)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -59,11 +58,11 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
||||
return &response.Album, nil
|
||||
}
|
||||
|
||||
func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) {
|
||||
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("artist", name)
|
||||
params.Add("lang", c.lang)
|
||||
params.Add("lang", lang)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -22,7 +22,7 @@ var _ = Describe("client", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client = newClient("API_KEY", "SECRET", "pt", httpClient)
|
||||
client = newClient("API_KEY", "SECRET", httpClient)
|
||||
})
|
||||
|
||||
Describe("albumGetInfo", func() {
|
||||
@ -30,7 +30,7 @@ var _ = Describe("client", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234")
|
||||
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(album.Name).To(Equal("Believe"))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
||||
@ -42,7 +42,7 @@ var _ = Describe("client", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
artist, err := client.artistGetInfo(context.Background(), "U2")
|
||||
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||
Expect(err).To(BeNil())
|
||||
Expect(artist.Name).To(Equal("U2"))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||
@ -54,7 +54,7 @@ var _ = Describe("client", func() {
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||
Expect(err).To(MatchError("last.fm http status: (500)"))
|
||||
})
|
||||
|
||||
@ -64,7 +64,7 @@ var _ = Describe("client", func() {
|
||||
StatusCode: 400,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
|
||||
})
|
||||
|
||||
@ -74,14 +74,14 @@ var _ = Describe("client", func() {
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
||||
})
|
||||
|
||||
It("fails if HttpClient.Do() returns error", func() {
|
||||
httpClient.Err = errors.New("generic error")
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||
Expect(err).To(MatchError("generic error"))
|
||||
})
|
||||
|
||||
@ -91,7 +91,7 @@ var _ = Describe("client", func() {
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
_, err := client.artistGetInfo(context.Background(), "U2")
|
||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
||||
})
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -174,6 +175,9 @@ type lastfmOptions struct {
|
||||
Secret string
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
|
||||
// Computed values
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type spotifyOptions struct {
|
||||
@ -184,6 +188,9 @@ type spotifyOptions struct {
|
||||
type deezerOptions struct {
|
||||
Enabled bool
|
||||
Language string
|
||||
|
||||
// Computed values
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
@ -370,6 +377,16 @@ func Load(noConfigDump bool) {
|
||||
disableExternalServices()
|
||||
}
|
||||
|
||||
// Make sure we don't have empty PIDs
|
||||
Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
|
||||
Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
|
||||
|
||||
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||
Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
|
||||
|
||||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
@ -458,6 +475,22 @@ func validatePlaylistsPath() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLanguages parses a comma-separated language string into a slice.
|
||||
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
|
||||
func parseLanguages(lang string) []string {
|
||||
var languages []string
|
||||
for _, l := range strings.Split(lang, ",") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
languages = append(languages, l)
|
||||
}
|
||||
}
|
||||
if len(languages) == 0 {
|
||||
return []string{consts.DefaultInfoLanguage}
|
||||
}
|
||||
return languages
|
||||
}
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := false
|
||||
@ -611,17 +644,18 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||
viper.SetDefault("agents", "lastfm,spotify,deezer")
|
||||
viper.SetDefault("lastfm.enabled", true)
|
||||
viper.SetDefault("lastfm.language", "en")
|
||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("lastfm.apikey", "")
|
||||
viper.SetDefault("lastfm.secret", "")
|
||||
viper.SetDefault("lastfm.scrobblefirstartistonly", false)
|
||||
viper.SetDefault("spotify.id", "")
|
||||
viper.SetDefault("spotify.secret", "")
|
||||
viper.SetDefault("deezer.enabled", true)
|
||||
viper.SetDefault("deezer.language", "en")
|
||||
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.enablemetadata", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
@ -637,7 +671,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("plugins.folder", "")
|
||||
viper.SetDefault("plugins.enabled", false)
|
||||
viper.SetDefault("plugins.enabled", true)
|
||||
viper.SetDefault("plugins.cachesize", "200MB")
|
||||
viper.SetDefault("plugins.autoreload", false)
|
||||
|
||||
|
||||
@ -26,6 +26,32 @@ var _ = Describe("Configuration", func() {
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
Describe("ParseLanguages", func() {
|
||||
It("parses single language", func() {
|
||||
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
|
||||
})
|
||||
|
||||
It("parses multiple comma-separated languages", func() {
|
||||
Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
|
||||
})
|
||||
|
||||
It("trims whitespace from languages", func() {
|
||||
Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
|
||||
})
|
||||
|
||||
It("returns default 'en' when empty", func() {
|
||||
Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
|
||||
})
|
||||
|
||||
It("returns default 'en' when only whitespace", func() {
|
||||
Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
|
||||
})
|
||||
|
||||
It("handles multiple languages with various spacing", func() {
|
||||
Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("should load configuration from",
|
||||
func(format string) {
|
||||
filename := filepath.Join("testdata", "cfg."+format)
|
||||
|
||||
@ -5,3 +5,5 @@ func ResetConf() {
|
||||
}
|
||||
|
||||
var SetViperDefaults = setViperDefaults
|
||||
|
||||
var ParseLanguages = parseLanguages
|
||||
|
||||
@ -56,6 +56,8 @@ const (
|
||||
|
||||
ServerReadHeaderTimeout = 3 * time.Second
|
||||
|
||||
DefaultInfoLanguage = "en"
|
||||
|
||||
ArtistInfoTimeToLive = 24 * time.Hour
|
||||
AlbumInfoTimeToLive = 7 * 24 * time.Hour
|
||||
UpdateLastAccessFrequency = time.Minute
|
||||
|
||||
@ -36,6 +36,7 @@ type Song struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ISRC string
|
||||
Artist string
|
||||
ArtistMBID string
|
||||
Album string
|
||||
|
||||
@ -302,6 +302,33 @@ var _ = Describe("Artwork", func() {
|
||||
Entry("landscape jpg image", "jpg", true, 200),
|
||||
)
|
||||
})
|
||||
When("Requested size is larger than original", func() {
|
||||
It("clamps size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
// front.png is 16x16, requesting 99999 should return at original size
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should be clamped to original size (16), not 99999
|
||||
Expect(img.Bounds().Size().X).To(Equal(16))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(16))
|
||||
})
|
||||
|
||||
It("clamps square size to original dimensions", func() {
|
||||
conf.Server.CoverArtPriority = "front.png"
|
||||
// front.png is 16x16, requesting 99999 with square should return 16x16 square
|
||||
r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
img, _, err := image.Decode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should be clamped to original size (16), not 99999
|
||||
Expect(img.Bounds().Size().X).To(Equal(16))
|
||||
Expect(img.Bounds().Size().Y).To(Equal(16))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -87,6 +87,11 @@ func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error
|
||||
bounds := original.Bounds()
|
||||
originalSize := max(bounds.Max.X, bounds.Max.Y)
|
||||
|
||||
// Clamp size to original dimensions - upscaling wastes resources and adds no information
|
||||
if size > originalSize {
|
||||
size = originalSize
|
||||
}
|
||||
|
||||
if originalSize <= size && !square {
|
||||
return nil, originalSize, nil
|
||||
}
|
||||
|
||||
5
core/external/extdata_helper_test.go
vendored
5
core/external/extdata_helper_test.go
vendored
@ -92,6 +92,11 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
return args.Get(0).(*model.MediaFile), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAllByTags implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
}
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
|
||||
240
core/external/provider_matching.go
vendored
240
core/external/provider_matching.go
vendored
@ -13,28 +13,24 @@ import (
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
// durationToleranceSec is the maximum allowed difference in seconds when
|
||||
// matching tracks by duration. A tolerance of 3 seconds accounts for minor
|
||||
// encoding differences between sources.
|
||||
const durationToleranceSec = 3
|
||||
|
||||
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
// # Algorithm Overview
|
||||
//
|
||||
// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
|
||||
// local music library using three matching strategies in priority order:
|
||||
// local music library using four matching strategies in priority order:
|
||||
//
|
||||
// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
|
||||
// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
|
||||
// matching mbz_recording_id
|
||||
// 3. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
|
||||
// 3. ISRC match: Songs with ISRC are matched to tracks with matching ISRC tag
|
||||
// 4. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
|
||||
// with metadata specificity scoring
|
||||
//
|
||||
// # Matching Priority
|
||||
//
|
||||
// When selecting the final result, matches are prioritized in order: ID > MBID > Title+Artist.
|
||||
// When selecting the final result, matches are prioritized in order: ID > MBID > ISRC > Title+Artist.
|
||||
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
@ -43,14 +39,15 @@ const durationToleranceSec = 3
|
||||
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||
// 2. Specificity level (0-5, based on metadata precision):
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Specificity level (0-5, based on metadata precision):
|
||||
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// - Level 2: Title + Artist MBID
|
||||
// - Level 1: Title + Artist name
|
||||
// - Level 0: Title only
|
||||
// 3. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
@ -63,7 +60,16 @@ const durationToleranceSec = 3
|
||||
// ]
|
||||
// Result: t1 (MBID match takes priority over title+artist)
|
||||
//
|
||||
// Example 2 - Specificity Ranking:
|
||||
// Example 2 - ISRC Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", Tags: {isrc: ["GBAYE0000351"]}},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (ISRC match takes priority over title+artist)
|
||||
//
|
||||
// Example 3 - Specificity Ranking:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has: [
|
||||
@ -72,7 +78,7 @@ const durationToleranceSec = 3
|
||||
// ]
|
||||
// Result: t2 (Level 3 beats Level 1 due to album match)
|
||||
//
|
||||
// Example 3 - Fuzzy Title Matching:
|
||||
// Example 4 - Fuzzy Title Matching:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
@ -94,16 +100,43 @@ func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches)
|
||||
isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, titleMatches, count), nil
|
||||
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil
|
||||
}
|
||||
|
||||
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
|
||||
// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys.
|
||||
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
|
||||
_, found := lookupByIdentifiers(s, priorMatches...)
|
||||
return found
|
||||
}
|
||||
|
||||
// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
|
||||
// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match.
|
||||
func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
keys := []string{s.ID, s.MBID, s.ISRC}
|
||||
for _, m := range maps {
|
||||
for _, key := range keys {
|
||||
if key != "" {
|
||||
if mf, ok := m[key]; ok && mf.ID != "" {
|
||||
return mf, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
|
||||
@ -142,10 +175,10 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map
|
||||
// It extracts all non-empty MBID fields from the input songs and performs a single
|
||||
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" {
|
||||
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
@ -172,6 +205,37 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (m
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard
|
||||
// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and
|
||||
// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var isrcs []string
|
||||
for _, s := range songs {
|
||||
if s.ISRC != "" && !songMatchedIn(s, priorMatches...) {
|
||||
isrcs = append(isrcs, s.ISRC)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(isrcs) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{
|
||||
Filters: squirrel.Eq{"missing": false},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
for _, isrc := range mf.Tags.Values(model.TagISRC) {
|
||||
if _, ok := matches[isrc]; !ok {
|
||||
matches[isrc] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// songQuery represents a normalized query for matching a song to library tracks.
|
||||
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
|
||||
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
|
||||
@ -186,17 +250,21 @@ type songQuery struct {
|
||||
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
||||
type matchScore struct {
|
||||
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
|
||||
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
|
||||
specificityLevel int // 0-5 (higher = more specific metadata match)
|
||||
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
|
||||
durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown)
|
||||
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
|
||||
specificityLevel int // 0-5 (higher = more specific metadata match)
|
||||
}
|
||||
|
||||
// betterThan returns true if this score beats another.
|
||||
// Comparison order: title similarity > specificity level > album similarity
|
||||
// Comparison order: title similarity > duration proximity > specificity level > album similarity
|
||||
func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.titleSimilarity != other.titleSimilarity {
|
||||
return s.titleSimilarity > other.titleSimilarity
|
||||
}
|
||||
if s.durationProximity != other.durationProximity {
|
||||
return s.durationProximity > other.durationProximity
|
||||
}
|
||||
if s.specificityLevel != other.specificityLevel {
|
||||
return s.specificityLevel > other.specificityLevel
|
||||
}
|
||||
@ -246,8 +314,8 @@ func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold flo
|
||||
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
|
||||
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
|
||||
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
|
||||
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := e.buildTitleQueries(songs, idMatches, mbidMatches)
|
||||
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := e.buildTitleQueries(songs, priorMatches...)
|
||||
if len(queries) == 0 {
|
||||
return map[string]model.MediaFile{}, nil
|
||||
}
|
||||
@ -289,50 +357,26 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// durationMatches checks if a track's duration is within tolerance of the target duration.
|
||||
// Returns true if durationMs is 0 (unknown) or if the difference is within durationToleranceSec.
|
||||
func durationMatches(durationMs uint32, mediaFileDurationSec float32) bool {
|
||||
// durationProximity returns a score from 0.0 to 1.0 indicating how close
|
||||
// the track's duration is to the target. A perfect match returns 1.0, and the
|
||||
// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0
|
||||
// if durationMs is 0 (unknown), so duration does not influence scoring.
|
||||
func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 {
|
||||
if durationMs <= 0 {
|
||||
return true // Unknown duration matches anything
|
||||
return 1.0 // Unknown duration — don't penalise
|
||||
}
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
|
||||
return diff <= durationToleranceSec
|
||||
return 1.0 / (1.0 + diff)
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
// When duration is known (durationMs > 0), it acts as a top-priority filter:
|
||||
// - First, only tracks with matching duration (±3 seconds) are considered
|
||||
// - If no title match is found among duration-filtered tracks, falls back to matching all tracks
|
||||
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
||||
// 1. Highest title similarity
|
||||
// 2. Highest specificity level
|
||||
// 3. Highest album similarity (as final tiebreaker)
|
||||
// 2. Duration proximity (closer duration = higher score, 1.0 if unknown)
|
||||
// 3. Highest specificity level
|
||||
// 4. Highest album similarity (as final tiebreaker)
|
||||
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||
// If duration is known, try to find matches among duration-filtered tracks first
|
||||
if q.durationMs > 0 {
|
||||
var durationFiltered model.MediaFiles
|
||||
for _, mf := range tracks {
|
||||
if durationMatches(q.durationMs, mf.Duration) {
|
||||
durationFiltered = append(durationFiltered, mf)
|
||||
}
|
||||
}
|
||||
// If we have duration-filtered candidates, try matching those first
|
||||
if len(durationFiltered) > 0 {
|
||||
if mf, found := findBestMatchInTracks(q, durationFiltered, threshold); found {
|
||||
return mf, true
|
||||
}
|
||||
}
|
||||
// Fall through to try all tracks if no duration-filtered match found
|
||||
}
|
||||
|
||||
return findBestMatchInTracks(q, tracks, threshold)
|
||||
}
|
||||
|
||||
// findBestMatchInTracks performs the core matching logic on a set of tracks.
|
||||
// It finds the track with the best combined score based on title similarity,
|
||||
// specificity level, and album similarity.
|
||||
func findBestMatchInTracks(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
@ -353,9 +397,10 @@ func findBestMatchInTracks(q songQuery, tracks model.MediaFiles, threshold float
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, mf, threshold),
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, mf.Duration),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, mf, threshold),
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
@ -367,14 +412,13 @@ func findBestMatchInTracks(q songQuery, tracks model.MediaFiles, threshold float
|
||||
return bestMatch, found
|
||||
}
|
||||
|
||||
func (e *provider) buildTitleQueries(songs []agents.Song, idMatches, mbidMatches map[string]model.MediaFile) []songQuery {
|
||||
// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
|
||||
// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes
|
||||
// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names).
|
||||
func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
for _, s := range songs {
|
||||
// Skip if already matched by ID or MBID
|
||||
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||
if songMatchedIn(s, priorMatches...) {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, songQuery{
|
||||
@ -389,35 +433,61 @@ func (e *provider) buildTitleQueries(songs []agents.Song, idMatches, mbidMatches
|
||||
return queries
|
||||
}
|
||||
|
||||
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
|
||||
// library tracks. It iterates through the input songs in order and selects the first available match
|
||||
// using priority order: ID > MBID > ISRC > title+artist.
|
||||
//
|
||||
// The function also handles deduplication: when multiple different input songs would match the same
|
||||
// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library),
|
||||
// only the first match is kept. However, if the same input song appears multiple times (intentional
|
||||
// repetition), duplicates are preserved in the output.
|
||||
//
|
||||
// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped.
|
||||
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
mfs := make(model.MediaFiles, 0, len(songs))
|
||||
// Track MediaFile.ID -> input song that added it, for deduplication
|
||||
addedBy := make(map[string]agents.Song, len(songs))
|
||||
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
// Try ID match first
|
||||
if t.ID != "" {
|
||||
if mf, ok := byID[t.ID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
|
||||
mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicate library track
|
||||
if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
|
||||
// Only add duplicate if input songs are identical
|
||||
if t != prevSong {
|
||||
continue // Different input songs → skip mismatch-induced duplicate
|
||||
}
|
||||
} else {
|
||||
addedBy[mf.ID] = t
|
||||
}
|
||||
// Try MBID match second
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall back to title+artist match (composite key preserves duplicate titles)
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist.
|
||||
// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists.
|
||||
func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
|
||||
// Try identifier-based matches first (ID, MBID, ISRC)
|
||||
if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
|
||||
return mf, true
|
||||
}
|
||||
// Fall back to title+artist fuzzy match
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
return mf, true
|
||||
}
|
||||
return model.MediaFile{}, false
|
||||
}
|
||||
|
||||
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
||||
// Returns a value between 0.0 (completely different) and 1.0 (identical).
|
||||
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
|
||||
|
||||
179
core/external/provider_matching_test.go
vendored
179
core/external/provider_matching_test.go
vendored
@ -458,7 +458,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Duration filtering", func() {
|
||||
Describe("Duration matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -496,48 +496,48 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches within 3-second tolerance", func() {
|
||||
It("matches tracks with close duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has track with 182 seconds (within tolerance)
|
||||
withinTolerance := model.MediaFile{
|
||||
ID: "within-tolerance", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
// Library has track with 182.5 seconds (close to target)
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{withinTolerance})
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("within-tolerance"))
|
||||
Expect(songs[0].ID).To(Equal("close-duration"))
|
||||
})
|
||||
|
||||
It("excludes tracks outside 3-second tolerance when other matches exist", func() {
|
||||
It("prefers closer duration over farther duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has one within tolerance, one outside
|
||||
withinTolerance := model.MediaFile{
|
||||
ID: "within", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
// Library has one close, one far
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
outsideTolerance := model.MediaFile{
|
||||
ID: "outside", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{outsideTolerance, withinTolerance})
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("within"))
|
||||
Expect(songs[0].ID).To(Equal("close"))
|
||||
})
|
||||
|
||||
It("falls back to normal matching when no duration matches", func() {
|
||||
It("still matches when no tracks have matching duration", func() {
|
||||
// Agent returns song with duration 180000ms
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
@ -552,19 +552,19 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back and return the track despite duration mismatch
|
||||
// Duration mismatch doesn't exclude the track; it's just scored lower
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("different"))
|
||||
})
|
||||
|
||||
It("falls back to title match when duration-filtered tracks fail title threshold", func() {
|
||||
It("prefers title match over duration match when titles differ", func() {
|
||||
// Agent returns "Similar Song" with duration 180000ms
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has:
|
||||
// - differentTitle: matches duration but has different title (won't pass title threshold)
|
||||
// - correctTitle: doesn't match duration but has correct title (should be found via fallback)
|
||||
// - correctTitle: doesn't match duration but has correct title (wins on title similarity)
|
||||
differentTitle := model.MediaFile{
|
||||
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
@ -577,7 +577,7 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should fall back to all tracks and find the title match
|
||||
// Title similarity is the top priority, so the correct title wins despite duration mismatch
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-title"))
|
||||
})
|
||||
@ -605,8 +605,8 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
It("handles very short songs with duration tolerance", func() {
|
||||
// 30-second song with 1-second difference (within 3-second tolerance)
|
||||
It("handles very short songs with close duration", func() {
|
||||
// 30-second song with 1-second difference
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
|
||||
}
|
||||
@ -624,4 +624,139 @@ var _ = Describe("Provider - Song Matching", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Deduplication of mismatched songs", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
|
||||
|
||||
// Setup for GetEntityByID to return the track
|
||||
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
||||
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
||||
})
|
||||
|
||||
It("removes duplicates when different input songs match the same library track", func() {
|
||||
// Agent returns two different versions that will both fuzzy-match to the same library track
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
{Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
|
||||
}
|
||||
// Library only has one version
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should only return one track, not two duplicates
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("br-live"))
|
||||
})
|
||||
|
||||
It("preserves duplicates when identical input songs match the same library track", func() {
|
||||
// Agent returns the exact same song twice (intentional repetition)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
// Library has matching track
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return two tracks since input songs were identical
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("br"))
|
||||
Expect(songs[1].ID).To(Equal("br"))
|
||||
})
|
||||
|
||||
It("handles mixed scenario with both identical and different input songs", func() {
|
||||
// Agent returns: Song A, Song B (different from A), Song A again (same as first)
|
||||
// All three match to the same library track
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first
|
||||
{Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version
|
||||
}
|
||||
// Library only has one version
|
||||
libraryTrack := model.MediaFile{
|
||||
ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return 2 tracks:
|
||||
// 1. First "Yesterday" (original)
|
||||
// 2. Third "Yesterday" (same as first, so kept)
|
||||
// Skip: Second "Yesterday (Remastered)" (different input, same library track)
|
||||
// Skip: Fourth "Yesterday (Anthology)" (different input, same library track)
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("yesterday"))
|
||||
Expect(songs[1].ID).To(Equal("yesterday"))
|
||||
})
|
||||
|
||||
It("does not deduplicate songs that match different library tracks", func() {
|
||||
// Agent returns different songs that match different library tracks
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song C", Artist: "Artist"},
|
||||
}
|
||||
// Library has all three songs
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All three should be returned since they match different library tracks
|
||||
Expect(songs).To(HaveLen(3))
|
||||
Expect(songs[0].ID).To(Equal("track-a"))
|
||||
Expect(songs[1].ID).To(Equal("track-b"))
|
||||
Expect(songs[2].ID).To(Equal("track-c"))
|
||||
})
|
||||
|
||||
It("respects count limit after deduplication", func() {
|
||||
// Agent returns 4 songs: 2 unique + 2 that would create duplicates
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist"},
|
||||
{Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track
|
||||
{Name: "Song B", Artist: "Artist"},
|
||||
{Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track
|
||||
}
|
||||
trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
|
||||
trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB})
|
||||
|
||||
// Request only 2 songs
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Should return exactly 2: Song A and Song B (skipping duplicates)
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("track-a"))
|
||||
Expect(songs[1].ID).To(Equal("track-b"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -359,6 +359,7 @@ type MediaFileRepository interface {
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetWithParticipants(id string) (*MediaFile, error)
|
||||
GetAll(options ...QueryOptions) (MediaFiles, error)
|
||||
GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error)
|
||||
GetCursor(options ...QueryOptions) (MediaFileCursor, error)
|
||||
Delete(id string) error
|
||||
DeleteMissing(ids []string) error
|
||||
|
||||
@ -195,6 +195,31 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media
|
||||
return res.toModels(), nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
placeholders := make([]string, len(values))
|
||||
args := make([]any, len(values))
|
||||
for i, v := range values {
|
||||
placeholders[i] = "?"
|
||||
args[i] = v
|
||||
}
|
||||
tagFilter := Expr(
|
||||
fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and value in (%s))",
|
||||
tag, strings.Join(placeholders, ",")),
|
||||
args...,
|
||||
)
|
||||
|
||||
var opts model.QueryOptions
|
||||
if len(options) > 0 {
|
||||
opts = options[0]
|
||||
}
|
||||
if opts.Filters != nil {
|
||||
opts.Filters = And{tagFilter, opts.Filters}
|
||||
} else {
|
||||
opts.Filters = tagFilter
|
||||
}
|
||||
return r.GetAll(opts)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) {
|
||||
sq := r.selectMediaFile(options...)
|
||||
cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq)
|
||||
|
||||
@ -1036,9 +1036,8 @@ See [examples/](examples/) for complete working plugins:
|
||||
| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive |
|
||||
| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks |
|
||||
| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger |
|
||||
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||||
| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging |
|
||||
| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo |
|
||||
| [discord-rich-presence](examples/discord-rich-presence/) | Go | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration |
|
||||
| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) |
|
||||
|
||||
---
|
||||
|
||||
@ -142,6 +142,8 @@ type SongRef struct {
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// ISRC is the International Standard Recording Code for the song.
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
// Artist is the artist name.
|
||||
Artist string `json:"artist,omitempty"`
|
||||
// ArtistMBID is the MusicBrainz artist ID.
|
||||
|
||||
@ -343,6 +343,9 @@ components:
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz ID for the song.
|
||||
isrc:
|
||||
type: string
|
||||
description: ISRC is the International Standard Recording Code for the song.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the artist name.
|
||||
|
||||
@ -9,7 +9,6 @@ This folder contains example plugins demonstrating various capabilities and lang
|
||||
| [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure |
|
||||
| [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata |
|
||||
| [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) |
|
||||
| [discord-rich-presence](discord-rich-presence/) | Go | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration |
|
||||
| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive |
|
||||
| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger |
|
||||
| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble |
|
||||
@ -37,7 +36,7 @@ This creates `.ndp` package files for each plugin.
|
||||
```bash
|
||||
make minimal.ndp
|
||||
make wikimedia.ndp
|
||||
make discord-rich-presence.ndp
|
||||
make discord-rich-presence-rs.ndp
|
||||
```
|
||||
|
||||
### Clean
|
||||
|
||||
@ -60,9 +60,6 @@
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"config": {
|
||||
"reason": "To read ticker symbols configuration"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule reconnection attempts on connection loss"
|
||||
},
|
||||
|
||||
@ -29,7 +29,7 @@ This plugin implements multiple capabilities to demonstrate the nd-pdk library:
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
|
||||
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence-rs):
|
||||
|
||||
| Key | Description | Example |
|
||||
|---------------|--------------------------------------|---------------------------|
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
# Discord Rich Presence Plugin
|
||||
|
||||
This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time connection to an external service while remaining completely stateless. This plugin is based on the [Navicord](https://github.com/logixism/navicord) project, which provides similar functionality.
|
||||
|
||||
**⚠️ WARNING: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the Navidrome configuration file, which is not secure and may be against Discord's terms of service. Use it at your own risk.**
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin exposes three capabilities:
|
||||
|
||||
- **Scrobbler** – receives `NowPlaying` notifications from Navidrome
|
||||
- **WebSocketCallback** – handles Discord gateway messages
|
||||
- **SchedulerCallback** – used to clear presence and send periodic heartbeats
|
||||
|
||||
It relies on several host services declared in the manifest:
|
||||
|
||||
- `http` – queries Discord API endpoints
|
||||
- `websocket` – maintains gateway connections
|
||||
- `scheduler` – schedules heartbeats and presence cleanup
|
||||
- `cache` – stores sequence numbers for heartbeats
|
||||
- `artwork` – resolves track artwork URLs
|
||||
|
||||
## Architecture
|
||||
|
||||
The plugin registers capabilities using the PDK Register pattern:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
type discordPlugin struct{}
|
||||
|
||||
func init() {
|
||||
scrobbler.Register(&discordPlugin{})
|
||||
scheduler.Register(&discordPlugin{})
|
||||
websocket.Register(&discordPlugin{})
|
||||
}
|
||||
```
|
||||
|
||||
The PDK generates the appropriate export wrappers automatically.
|
||||
|
||||
When `NowPlaying` is invoked the plugin:
|
||||
|
||||
1. Loads `clientid` and user tokens from the configuration (because plugins are stateless).
|
||||
2. Connects to Discord using `WebSocketService` if no connection exists.
|
||||
3. Sends the activity payload with track details and artwork.
|
||||
4. Schedules a one-time callback to clear the presence after the track finishes.
|
||||
|
||||
Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in `CacheService` to remain available across plugin instances.
|
||||
|
||||
The scheduler callback uses the `payload` field to route to the appropriate handler:
|
||||
- `"heartbeat"` – sends a heartbeat to Discord (recurring)
|
||||
- `"clear-activity"` – clears the presence and disconnects (one-time)
|
||||
|
||||
## Stateless Operation
|
||||
|
||||
Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it afterwards.
|
||||
|
||||
To work within this model the plugin stores no in-memory state. Connections are keyed by username inside the host services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every method call.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
|
||||
|
||||
| Key | Description | Example |
|
||||
|---------------|-------------------------------------------|--------------------------------|
|
||||
| `clientid` | Your Discord application ID | `123456789012345678` |
|
||||
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
|
||||
|
||||
Each user is configured as a separate key with the `user.` prefix.
|
||||
|
||||
## Building
|
||||
|
||||
From the `plugins/examples/` directory:
|
||||
|
||||
```sh
|
||||
make discord-rich-presence.ndp
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```sh
|
||||
cd discord-rich-presence
|
||||
tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm .
|
||||
zip -j discord-rich-presence.ndp manifest.json plugin.wasm
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Place the resulting `discord-rich-presence.ndp` in your Navidrome plugins folder and enable plugins in your configuration:
|
||||
|
||||
```toml
|
||||
[Plugins]
|
||||
Enabled = true
|
||||
Folder = "/path/to/plugins"
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|-----------|------------------------------------------------------------------|
|
||||
| `main.go` | Plugin entry point, capability registration, and implementations |
|
||||
| `rpc.go` | Discord gateway communication and RPC logic |
|
||||
| `go.mod` | Go module file |
|
||||
|
||||
## PDK
|
||||
|
||||
This plugin imports the Navidrome PDK subpackages directly:
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
```
|
||||
|
||||
The `go.mod` file uses `replace` directives to point to the local packages for development.
|
||||
|
||||
## Host Services Used
|
||||
|
||||
| Service | Purpose |
|
||||
|-----------|------------------------------------------------------------------|
|
||||
| Cache | Store Discord sequence numbers and processed image URLs |
|
||||
| Scheduler | Schedule heartbeats (recurring) and activity clearing (one-time) |
|
||||
| WebSocket | Maintain persistent connection to Discord gateway |
|
||||
| Artwork | Get track artwork URLs for rich presence display |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
See `main.go` and `rpc.go` for the complete implementation.
|
||||
@ -1,32 +0,0 @@
|
||||
module discord-rich-presence
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/extism/go-pdk v1.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go
|
||||
@ -1,73 +0,0 @@
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
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/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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.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=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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=
|
||||
@ -1,219 +0,0 @@
|
||||
// Discord Rich Presence Plugin for Navidrome
|
||||
//
|
||||
// This plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can
|
||||
// keep a real-time connection to an external service while remaining completely stateless.
|
||||
//
|
||||
// Capabilities: Scrobbler, SchedulerCallback, WebSocketCallback
|
||||
//
|
||||
// NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord
|
||||
// token being stored in the Navidrome configuration file, which is not secure and may be
|
||||
// against Discord's terms of service. Use it at your own risk.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
// Configuration keys
|
||||
const (
|
||||
clientIDKey = "clientid"
|
||||
usersKey = "users"
|
||||
)
|
||||
|
||||
// userToken represents a user-token mapping from the config
|
||||
type userToken struct {
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// discordPlugin implements the scrobbler and scheduler interfaces.
|
||||
type discordPlugin struct{}
|
||||
|
||||
// rpc handles Discord gateway communication (via websockets).
|
||||
var rpc = &discordRPC{}
|
||||
|
||||
// init registers the plugin capabilities
|
||||
func init() {
|
||||
scrobbler.Register(&discordPlugin{})
|
||||
scheduler.Register(&discordPlugin{})
|
||||
websocket.Register(rpc)
|
||||
}
|
||||
|
||||
// getConfig loads the plugin configuration.
|
||||
func getConfig() (clientID string, users map[string]string, err error) {
|
||||
clientID, ok := pdk.GetConfig(clientIDKey)
|
||||
if !ok || clientID == "" {
|
||||
pdk.Log(pdk.LogWarn, "missing ClientID in configuration")
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// Get the users array from config
|
||||
usersJSON, ok := pdk.GetConfig(usersKey)
|
||||
if !ok || usersJSON == "" {
|
||||
pdk.Log(pdk.LogWarn, "no users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
// Parse the JSON array
|
||||
var userTokens []userToken
|
||||
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
if len(userTokens) == 0 {
|
||||
pdk.Log(pdk.LogWarn, "no users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
// Build the users map
|
||||
users = make(map[string]string)
|
||||
for _, ut := range userTokens {
|
||||
if ut.Username != "" && ut.Token != "" {
|
||||
users[ut.Username] = ut.Token
|
||||
}
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
pdk.Log(pdk.LogWarn, "no valid users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
return clientID, users, nil
|
||||
}
|
||||
|
||||
// getImageURL retrieves the track artwork URL.
|
||||
func getImageURL(trackID string) string {
|
||||
artworkURL, err := host.ArtworkGetTrackUrl(trackID, 300)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to get artwork URL: %v", err))
|
||||
return ""
|
||||
}
|
||||
|
||||
// Don't use localhost URLs
|
||||
if strings.HasPrefix(artworkURL, "http://localhost") {
|
||||
return ""
|
||||
}
|
||||
return artworkURL
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scrobbler Implementation
|
||||
// ============================================================================
|
||||
|
||||
// IsAuthorized checks if a user is authorized for Discord Rich Presence.
|
||||
func (p *discordPlugin) IsAuthorized(input scrobbler.IsAuthorizedRequest) (bool, error) {
|
||||
_, users, err := getConfig()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check user authorization: %w", err)
|
||||
}
|
||||
|
||||
_, authorized := users[input.Username]
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("IsAuthorized for user %s: %v", input.Username, authorized))
|
||||
return authorized, nil
|
||||
}
|
||||
|
||||
// NowPlaying sends a now playing notification to Discord.
|
||||
func (p *discordPlugin) NowPlaying(input scrobbler.NowPlayingRequest) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Setting presence for user %s, track: %s", input.Username, input.Track.Title))
|
||||
|
||||
// Load configuration
|
||||
clientID, users, err := getConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: failed to get config: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
userToken, authorized := users[input.Username]
|
||||
if !authorized {
|
||||
return fmt.Errorf("%w: user '%s' not authorized", scrobbler.ScrobblerErrorNotAuthorized, input.Username)
|
||||
}
|
||||
|
||||
// Connect to Discord
|
||||
if err := rpc.connect(input.Username, userToken); err != nil {
|
||||
return fmt.Errorf("%w: failed to connect to Discord: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
}
|
||||
|
||||
// Cancel any existing completion schedule
|
||||
_ = host.SchedulerCancelSchedule(fmt.Sprintf("%s-clear", input.Username))
|
||||
|
||||
// Calculate timestamps
|
||||
now := time.Now().Unix()
|
||||
startTime := (now - int64(input.Position)) * 1000
|
||||
endTime := startTime + int64(input.Track.Duration)*1000
|
||||
|
||||
// Send activity update
|
||||
if err := rpc.sendActivity(clientID, input.Username, userToken, activity{
|
||||
Application: clientID,
|
||||
Name: "Navidrome",
|
||||
Type: 2, // Listening
|
||||
Details: input.Track.Title,
|
||||
State: input.Track.Artist,
|
||||
Timestamps: activityTimestamps{
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
},
|
||||
Assets: activityAssets{
|
||||
LargeImage: getImageURL(input.Track.ID),
|
||||
LargeText: input.Track.Album,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("%w: failed to send activity: %v", scrobbler.ScrobblerErrorRetryLater, err)
|
||||
}
|
||||
|
||||
// Schedule a timer to clear the activity after the track completes
|
||||
remainingSeconds := int32(input.Track.Duration) - input.Position + 5
|
||||
_, err = host.SchedulerScheduleOneTime(remainingSeconds, payloadClearActivity, fmt.Sprintf("%s-clear", input.Username))
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to schedule completion timer: %v", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scrobble handles scrobble requests (no-op for Discord).
|
||||
func (p *discordPlugin) Scrobble(_ scrobbler.ScrobbleRequest) error {
|
||||
// Discord Rich Presence doesn't need scrobble events
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scheduler Callback Implementation
|
||||
// ============================================================================
|
||||
|
||||
// OnCallback handles scheduler callbacks.
|
||||
func (p *discordPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Scheduler callback: id=%s, payload=%s, recurring=%v", input.ScheduleID, input.Payload, input.IsRecurring))
|
||||
|
||||
// Route based on payload
|
||||
switch input.Payload {
|
||||
case payloadHeartbeat:
|
||||
// Heartbeat callback - scheduleId is the username
|
||||
if err := rpc.handleHeartbeatCallback(input.ScheduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
case payloadClearActivity:
|
||||
// Clear activity callback - scheduleId is "username-clear"
|
||||
username := strings.TrimSuffix(input.ScheduleID, "-clear")
|
||||
if err := rpc.handleClearActivityCallback(username); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
default:
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Unknown scheduler callback payload: %s", input.Payload))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {}
|
||||
@ -1,227 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scheduler"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestDiscordPlugin(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Discord Plugin Main Suite")
|
||||
}
|
||||
|
||||
var _ = Describe("discordPlugin", func() {
|
||||
var plugin discordPlugin
|
||||
|
||||
BeforeEach(func() {
|
||||
plugin = discordPlugin{}
|
||||
pdk.ResetMock()
|
||||
host.CacheMock.ExpectedCalls = nil
|
||||
host.CacheMock.Calls = nil
|
||||
host.ConfigMock.ExpectedCalls = nil
|
||||
host.ConfigMock.Calls = nil
|
||||
host.WebSocketMock.ExpectedCalls = nil
|
||||
host.WebSocketMock.Calls = nil
|
||||
host.SchedulerMock.ExpectedCalls = nil
|
||||
host.SchedulerMock.Calls = nil
|
||||
host.ArtworkMock.ExpectedCalls = nil
|
||||
host.ArtworkMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("getConfig", func() {
|
||||
It("returns config values when properly set", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.user1", "user.user2"})
|
||||
host.ConfigMock.On("Get", "user.user1").Return("token1", true)
|
||||
host.ConfigMock.On("Get", "user.user2").Return("token2", true)
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
|
||||
clientID, users, err := getConfig()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(clientID).To(Equal("test-client-id"))
|
||||
Expect(users).To(HaveLen(2))
|
||||
Expect(users["user1"]).To(Equal("token1"))
|
||||
Expect(users["user2"]).To(Equal("token2"))
|
||||
})
|
||||
|
||||
It("returns empty client ID when not set", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("", false)
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
|
||||
clientID, users, err := getConfig()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(clientID).To(BeEmpty())
|
||||
Expect(users).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil users when users not configured", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{})
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
|
||||
clientID, users, err := getConfig()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(clientID).To(Equal("test-client-id"))
|
||||
Expect(users).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsAuthorized", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns true for authorized user", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"})
|
||||
host.ConfigMock.On("Get", "user.testuser").Return("token123", true)
|
||||
|
||||
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
|
||||
Username: "testuser",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(authorized).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for unauthorized user", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"})
|
||||
host.ConfigMock.On("Get", "user.otheruser").Return("token123", true)
|
||||
|
||||
authorized, err := plugin.IsAuthorized(scrobbler.IsAuthorizedRequest{
|
||||
Username: "testuser",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(authorized).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NowPlaying", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("returns not authorized error when user not in config", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.otheruser"})
|
||||
host.ConfigMock.On("Get", "user.otheruser").Return("token", true)
|
||||
|
||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||
Username: "testuser",
|
||||
Track: scrobbler.TrackInfo{Title: "Test Song"},
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, scrobbler.ScrobblerErrorNotAuthorized)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("successfully sends now playing update", func() {
|
||||
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
||||
host.ConfigMock.On("Keys", userKeyPrefix).Return([]string{"user.testuser"})
|
||||
host.ConfigMock.On("Get", "user.testuser").Return("test-token", true)
|
||||
|
||||
// Connect mocks (isConnected check via heartbeat)
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
|
||||
// Mock HTTP GET request for gateway discovery
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
gatewayReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(gatewayReq).Once()
|
||||
pdk.PDKMock.On("Send", gatewayReq).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp)).Once()
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "gateway.discord.gg")
|
||||
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
host.SchedulerMock.On("ScheduleRecurring", mock.Anything, payloadHeartbeat, "testuser").Return("testuser", nil)
|
||||
|
||||
// Cancel existing clear schedule (may or may not exist)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser-clear").Return(nil)
|
||||
|
||||
// Image mocks - cache miss, will make HTTP request to Discord
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
host.ArtworkMock.On("GetTrackUrl", "track1", int32(300)).Return("https://example.com/art.jpg", nil)
|
||||
|
||||
// Mock HTTP request for Discord external assets API
|
||||
assetsReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "external-assets")
|
||||
})).Return(assetsReq)
|
||||
pdk.PDKMock.On("Send", assetsReq).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
|
||||
// Schedule clear activity callback
|
||||
host.SchedulerMock.On("ScheduleOneTime", mock.Anything, payloadClearActivity, "testuser-clear").Return("testuser-clear", nil)
|
||||
|
||||
err := plugin.NowPlaying(scrobbler.NowPlayingRequest{
|
||||
Username: "testuser",
|
||||
Position: 10,
|
||||
Track: scrobbler.TrackInfo{
|
||||
ID: "track1",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
Duration: 180,
|
||||
},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
It("does nothing (returns nil)", func() {
|
||||
err := plugin.Scrobble(scrobbler.ScrobbleRequest{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnCallback", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
})
|
||||
|
||||
It("handles heartbeat callback", func() {
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
|
||||
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
|
||||
ScheduleID: "testuser",
|
||||
Payload: payloadHeartbeat,
|
||||
IsRecurring: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("handles clearActivity callback", func() {
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
|
||||
|
||||
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
|
||||
ScheduleID: "testuser-clear",
|
||||
Payload: payloadClearActivity,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("logs warning for unknown payload", func() {
|
||||
err := plugin.OnCallback(scheduler.SchedulerCallbackRequest{
|
||||
ScheduleID: "testuser",
|
||||
Payload: "unknown",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,102 +0,0 @@
|
||||
{
|
||||
"name": "Discord Rich Presence",
|
||||
"author": "Navidrome Team",
|
||||
"version": "1.0.0",
|
||||
"description": "Discord Rich Presence integration for Navidrome",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence",
|
||||
"permissions": {
|
||||
"users": {
|
||||
"reason": "To process scrobbles on behalf of users"
|
||||
},
|
||||
"http": {
|
||||
"reason": "To communicate with Discord API for gateway discovery and image uploads",
|
||||
"requiredHosts": [
|
||||
"discord.com"
|
||||
]
|
||||
},
|
||||
"websocket": {
|
||||
"reason": "To maintain real-time connection with Discord gateway",
|
||||
"requiredHosts": [
|
||||
"gateway.discord.gg"
|
||||
]
|
||||
},
|
||||
"cache": {
|
||||
"reason": "To store connection state and sequence numbers"
|
||||
},
|
||||
"scheduler": {
|
||||
"reason": "To schedule heartbeat messages and activity clearing"
|
||||
},
|
||||
"artwork": {
|
||||
"reason": "To get track artwork URLs for rich presence display"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"clientid": {
|
||||
"type": "string",
|
||||
"title": "Discord Application Client ID",
|
||||
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
|
||||
"minLength": 17,
|
||||
"maxLength": 20,
|
||||
"pattern": "^[0-9]+$"
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"title": "User Tokens",
|
||||
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "Navidrome Username",
|
||||
"description": "The Navidrome username to associate with this Discord token",
|
||||
"minLength": 1
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"title": "Discord Token",
|
||||
"description": "The user's Discord token (keep this secret!)",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["username", "token"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["clientid", "users"]
|
||||
},
|
||||
"uiSchema": {
|
||||
"type": "VerticalLayout",
|
||||
"elements": [
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/clientid"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/users",
|
||||
"options": {
|
||||
"elementLabelProp": "username",
|
||||
"detail": {
|
||||
"type": "HorizontalLayout",
|
||||
"elements": [
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/username"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/token"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,400 +0,0 @@
|
||||
// Discord Rich Presence Plugin - RPC Communication
|
||||
//
|
||||
// This file handles all Discord gateway communication including WebSocket connections,
|
||||
// presence updates, and heartbeat management. The discordRPC struct implements WebSocket
|
||||
// callback interfaces and encapsulates all Discord communication logic.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
)
|
||||
|
||||
// Discord WebSocket Gateway constants
|
||||
const (
|
||||
heartbeatOpCode = 1 // Heartbeat operation code
|
||||
gateOpCode = 2 // Identify operation code
|
||||
presenceOpCode = 3 // Presence update operation code
|
||||
)
|
||||
|
||||
const (
|
||||
heartbeatInterval = 41 // Heartbeat interval in seconds
|
||||
defaultImage = "https://i.imgur.com/hb3XPzA.png"
|
||||
)
|
||||
|
||||
// Scheduler callback payloads for routing
|
||||
const (
|
||||
payloadHeartbeat = "heartbeat"
|
||||
payloadClearActivity = "clear-activity"
|
||||
)
|
||||
|
||||
// discordRPC handles Discord gateway communication and implements WebSocket callbacks.
|
||||
type discordRPC struct{}
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket Callback Implementation
|
||||
// ============================================================================
|
||||
|
||||
// OnTextMessage handles incoming WebSocket text messages.
|
||||
func (r *discordRPC) OnTextMessage(input websocket.OnTextMessageRequest) error {
|
||||
return r.handleWebSocketMessage(input.ConnectionID, input.Message)
|
||||
}
|
||||
|
||||
// OnBinaryMessage handles incoming WebSocket binary messages.
|
||||
func (r *discordRPC) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Received unexpected binary message for connection '%s'", input.ConnectionID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnError handles WebSocket errors.
|
||||
func (r *discordRPC) OnError(input websocket.OnErrorRequest) error {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("WebSocket error for connection '%s': %s", input.ConnectionID, input.Error))
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnClose handles WebSocket connection closure.
|
||||
func (r *discordRPC) OnClose(input websocket.OnCloseRequest) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection '%s' closed with code %d: %s", input.ConnectionID, input.Code, input.Reason))
|
||||
return nil
|
||||
}
|
||||
|
||||
// activity represents a Discord activity.
|
||||
type activity struct {
|
||||
Name string `json:"name"`
|
||||
Type int `json:"type"`
|
||||
Details string `json:"details"`
|
||||
State string `json:"state"`
|
||||
Application string `json:"application_id"`
|
||||
Timestamps activityTimestamps `json:"timestamps"`
|
||||
Assets activityAssets `json:"assets"`
|
||||
}
|
||||
|
||||
type activityTimestamps struct {
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
}
|
||||
|
||||
type activityAssets struct {
|
||||
LargeImage string `json:"large_image"`
|
||||
LargeText string `json:"large_text"`
|
||||
}
|
||||
|
||||
// presencePayload represents a Discord presence update.
|
||||
type presencePayload struct {
|
||||
Activities []activity `json:"activities"`
|
||||
Since int64 `json:"since"`
|
||||
Status string `json:"status"`
|
||||
Afk bool `json:"afk"`
|
||||
}
|
||||
|
||||
// identifyPayload represents a Discord identify payload.
|
||||
type identifyPayload struct {
|
||||
Token string `json:"token"`
|
||||
Intents int `json:"intents"`
|
||||
Properties identifyProperties `json:"properties"`
|
||||
}
|
||||
|
||||
type identifyProperties struct {
|
||||
OS string `json:"os"`
|
||||
Browser string `json:"browser"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Image Processing
|
||||
// ============================================================================
|
||||
|
||||
// processImage processes an image URL for Discord, with fallback to default image.
|
||||
func (r *discordRPC) processImage(imageURL, clientID, token string, isDefaultImage bool) (string, error) {
|
||||
if imageURL == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("default image URL is empty")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(imageURL, "mp:") {
|
||||
return imageURL, nil
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("discord.image.%x", imageURL)
|
||||
cachedValue, exists, err := host.CacheGetString(cacheKey)
|
||||
if err == nil && exists {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cache hit for image URL: %s", imageURL))
|
||||
return cachedValue, nil
|
||||
}
|
||||
|
||||
// Process via Discord API
|
||||
body := fmt.Sprintf(`{"urls":[%q]}`, imageURL)
|
||||
req := pdk.NewHTTPRequest(pdk.MethodPost, fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID))
|
||||
req.SetHeader("Authorization", token)
|
||||
req.SetHeader("Content-Type", "application/json")
|
||||
req.SetBody([]byte(body))
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() >= 400 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to process default image: HTTP %d", resp.Status())
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
var data []map[string]string
|
||||
if err := json.Unmarshal(resp.Body(), &data); err != nil {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("failed to unmarshal default image response: %w", err)
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("no data returned for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
image := data[0]["external_asset_path"]
|
||||
if image == "" {
|
||||
if isDefaultImage {
|
||||
return "", fmt.Errorf("empty external_asset_path for default image")
|
||||
}
|
||||
return r.processImage(defaultImage, clientID, token, true)
|
||||
}
|
||||
|
||||
processedImage := fmt.Sprintf("mp:%s", image)
|
||||
|
||||
// Cache the processed image URL
|
||||
var ttl int64 = 4 * 60 * 60 // 4 hours for regular images
|
||||
if isDefaultImage {
|
||||
ttl = 48 * 60 * 60 // 48 hours for default image
|
||||
}
|
||||
|
||||
_ = host.CacheSetString(cacheKey, processedImage, ttl)
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Cached processed image URL for %s (TTL: %ds)", imageURL, ttl))
|
||||
|
||||
return processedImage, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Activity Management
|
||||
// ============================================================================
|
||||
|
||||
// sendActivity sends an activity update to Discord.
|
||||
func (r *discordRPC) sendActivity(clientID, username, token string, data activity) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Sending activity for user %s: %s - %s", username, data.Details, data.State))
|
||||
|
||||
processedImage, err := r.processImage(data.Assets.LargeImage, clientID, token, false)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to process image for user %s, continuing without image: %v", username, err))
|
||||
data.Assets.LargeImage = ""
|
||||
} else {
|
||||
data.Assets.LargeImage = processedImage
|
||||
}
|
||||
|
||||
presence := presencePayload{
|
||||
Activities: []activity{data},
|
||||
Status: "dnd",
|
||||
Afk: false,
|
||||
}
|
||||
return r.sendMessage(username, presenceOpCode, presence)
|
||||
}
|
||||
|
||||
// clearActivity clears the Discord activity for a user.
|
||||
func (r *discordRPC) clearActivity(username string) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Clearing activity for user %s", username))
|
||||
return r.sendMessage(username, presenceOpCode, presencePayload{})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Low-level Communication
|
||||
// ============================================================================
|
||||
|
||||
// sendMessage sends a message over the WebSocket connection.
|
||||
func (r *discordRPC) sendMessage(username string, opCode int, payload any) error {
|
||||
message := map[string]any{
|
||||
"op": opCode,
|
||||
"d": payload,
|
||||
}
|
||||
b, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
err = host.WebSocketSendText(username, string(b))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDiscordGateway retrieves the Discord gateway URL.
|
||||
func (r *discordRPC) getDiscordGateway() (string, error) {
|
||||
req := pdk.NewHTTPRequest(pdk.MethodGet, "https://discord.com/api/gateway")
|
||||
resp := req.Send()
|
||||
if resp.Status() != 200 {
|
||||
return "", fmt.Errorf("failed to get Discord gateway: HTTP %d", resp.Status())
|
||||
}
|
||||
|
||||
var result map[string]string
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse Discord gateway response: %w", err)
|
||||
}
|
||||
return result["url"], nil
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to Discord.
|
||||
func (r *discordRPC) sendHeartbeat(username string) error {
|
||||
seqNum, _, err := host.CacheGetInt(fmt.Sprintf("discord.seq.%s", username))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sequence number: %w", err)
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Sending heartbeat for user %s: %d", username, seqNum))
|
||||
return r.sendMessage(username, heartbeatOpCode, seqNum)
|
||||
}
|
||||
|
||||
// cleanupFailedConnection cleans up a failed Discord connection.
|
||||
func (r *discordRPC) cleanupFailedConnection(username string) {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaning up failed connection for user %s", username))
|
||||
|
||||
// Cancel the heartbeat schedule
|
||||
if err := host.SchedulerCancelSchedule(username); err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to cancel heartbeat schedule for user %s: %v", username, err))
|
||||
}
|
||||
|
||||
// Close the WebSocket connection
|
||||
if err := host.WebSocketCloseConnection(username, 1000, "Connection lost"); err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Failed to close WebSocket connection for user %s: %v", username, err))
|
||||
}
|
||||
|
||||
// Clean up cache entries
|
||||
_ = host.CacheRemove(fmt.Sprintf("discord.seq.%s", username))
|
||||
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Cleaned up connection for user %s", username))
|
||||
}
|
||||
|
||||
// isConnected checks if a user is connected to Discord by testing the heartbeat.
|
||||
func (r *discordRPC) isConnected(username string) bool {
|
||||
err := r.sendHeartbeat(username)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Heartbeat test failed for user %s: %v", username, err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// connect establishes a connection to Discord for a user.
|
||||
func (r *discordRPC) connect(username, token string) error {
|
||||
if r.isConnected(username) {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Reusing existing connection for user %s", username))
|
||||
return nil
|
||||
}
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Creating new connection for user %s", username))
|
||||
|
||||
// Get Discord Gateway URL
|
||||
gateway, err := r.getDiscordGateway()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get Discord gateway: %w", err)
|
||||
}
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("Using gateway: %s", gateway))
|
||||
|
||||
// Connect to Discord Gateway
|
||||
_, err = host.WebSocketConnect(gateway, nil, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
|
||||
// Send identify payload
|
||||
payload := identifyPayload{
|
||||
Token: token,
|
||||
Intents: 0,
|
||||
Properties: identifyProperties{
|
||||
OS: "Windows 10",
|
||||
Browser: "Discord Client",
|
||||
Device: "Discord Client",
|
||||
},
|
||||
}
|
||||
if err := r.sendMessage(username, gateOpCode, payload); err != nil {
|
||||
return fmt.Errorf("failed to send identify payload: %w", err)
|
||||
}
|
||||
|
||||
// Schedule heartbeats for this user/connection
|
||||
cronExpr := fmt.Sprintf("@every %ds", heartbeatInterval)
|
||||
scheduleID, err := host.SchedulerScheduleRecurring(cronExpr, payloadHeartbeat, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to schedule heartbeat: %w", err)
|
||||
}
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduled heartbeat for user %s with ID %s", username, scheduleID))
|
||||
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Successfully authenticated user %s", username))
|
||||
return nil
|
||||
}
|
||||
|
||||
// disconnect closes the Discord connection for a user.
|
||||
func (r *discordRPC) disconnect(username string) error {
|
||||
if err := host.SchedulerCancelSchedule(username); err != nil {
|
||||
return fmt.Errorf("failed to cancel schedule: %w", err)
|
||||
}
|
||||
|
||||
if err := host.WebSocketCloseConnection(username, 1000, "Navidrome disconnect"); err != nil {
|
||||
return fmt.Errorf("failed to close WebSocket connection: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleWebSocketMessage processes incoming WebSocket messages from Discord.
|
||||
func (r *discordRPC) handleWebSocketMessage(connectionID, message string) error {
|
||||
if len(message) < 1024 {
|
||||
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s': %s", connectionID, message))
|
||||
} else {
|
||||
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received WebSocket message for connection '%s' (truncated): %s...", connectionID, message[:1021]))
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
var msg map[string]any
|
||||
if err := json.Unmarshal([]byte(message), &msg); err != nil {
|
||||
return fmt.Errorf("failed to parse WebSocket message: %w", err)
|
||||
}
|
||||
|
||||
// Store sequence number if present
|
||||
if v := msg["s"]; v != nil {
|
||||
seq := int64(v.(float64))
|
||||
pdk.Log(pdk.LogTrace, fmt.Sprintf("Received sequence number for connection '%s': %d", connectionID, seq))
|
||||
if err := host.CacheSetInt(fmt.Sprintf("discord.seq.%s", connectionID), seq, int64(heartbeatInterval*2)); err != nil {
|
||||
return fmt.Errorf("failed to store sequence number for user %s: %w", connectionID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleHeartbeatCallback processes heartbeat scheduler callbacks.
|
||||
func (r *discordRPC) handleHeartbeatCallback(username string) error {
|
||||
if err := r.sendHeartbeat(username); err != nil {
|
||||
// On first heartbeat failure, immediately clean up the connection
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("Heartbeat failed for user %s, cleaning up connection: %v", username, err))
|
||||
r.cleanupFailedConnection(username)
|
||||
return fmt.Errorf("heartbeat failed, connection cleaned up: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleClearActivityCallback processes clear activity scheduler callbacks.
|
||||
func (r *discordRPC) handleClearActivityCallback(username string) error {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Removing presence for user %s", username))
|
||||
if err := r.clearActivity(username); err != nil {
|
||||
return fmt.Errorf("failed to clear activity: %w", err)
|
||||
}
|
||||
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Disconnecting user %s", username))
|
||||
if err := r.disconnect(username); err != nil {
|
||||
return fmt.Errorf("failed to disconnect from Discord: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -1,279 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/websocket"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("discordRPC", func() {
|
||||
var r *discordRPC
|
||||
|
||||
BeforeEach(func() {
|
||||
r = &discordRPC{}
|
||||
pdk.ResetMock()
|
||||
host.CacheMock.ExpectedCalls = nil
|
||||
host.CacheMock.Calls = nil
|
||||
host.WebSocketMock.ExpectedCalls = nil
|
||||
host.WebSocketMock.Calls = nil
|
||||
host.SchedulerMock.ExpectedCalls = nil
|
||||
host.SchedulerMock.Calls = nil
|
||||
})
|
||||
|
||||
Describe("sendMessage", func() {
|
||||
It("sends JSON message over WebSocket", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendMessage("testuser", presenceOpCode, map[string]string{"status": "online"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when WebSocket send fails", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", mock.Anything, mock.Anything).
|
||||
Return(errors.New("connection closed"))
|
||||
|
||||
err := r.sendMessage("testuser", presenceOpCode, map[string]string{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("connection closed"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sendHeartbeat", func() {
|
||||
It("retrieves sequence number from cache and sends heartbeat", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(123), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":1`) && strings.Contains(msg, "123")
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendHeartbeat("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.CacheMock.AssertExpectations(GinkgoT())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns error when cache get fails", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache error"))
|
||||
|
||||
err := r.sendHeartbeat("testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cache error"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("connect", func() {
|
||||
It("establishes WebSocket connection and sends identify payload", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("not found"))
|
||||
|
||||
// Mock HTTP GET request for gateway discovery
|
||||
gatewayResp := []byte(`{"url":"wss://gateway.discord.gg"}`)
|
||||
httpReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodGet, "https://discord.com/api/gateway").Return(httpReq)
|
||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, gatewayResp))
|
||||
|
||||
// Mock WebSocket connection
|
||||
host.WebSocketMock.On("Connect", mock.MatchedBy(func(url string) bool {
|
||||
return strings.Contains(url, "gateway.discord.gg")
|
||||
}), mock.Anything, "testuser").Return("testuser", nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":2`) && strings.Contains(msg, "test-token")
|
||||
})).Return(nil)
|
||||
host.SchedulerMock.On("ScheduleRecurring", "@every 41s", payloadHeartbeat, "testuser").
|
||||
Return("testuser", nil)
|
||||
|
||||
err := r.connect("testuser", "test-token")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("reuses existing connection if connected", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
|
||||
err := r.connect("testuser", "test-token")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.WebSocketMock.AssertNotCalled(GinkgoT(), "Connect", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("disconnect", func() {
|
||||
It("cancels schedule and closes WebSocket connection", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
|
||||
|
||||
err := r.disconnect("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host.SchedulerMock.AssertExpectations(GinkgoT())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("cleanupFailedConnection", func() {
|
||||
It("cancels schedule, closes WebSocket, and clears cache", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
|
||||
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
|
||||
|
||||
r.cleanupFailedConnection("testuser")
|
||||
|
||||
host.SchedulerMock.AssertExpectations(GinkgoT())
|
||||
host.WebSocketMock.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("handleHeartbeatCallback", func() {
|
||||
It("sends heartbeat successfully", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(42), true, nil)
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.Anything).Return(nil)
|
||||
|
||||
err := r.handleHeartbeatCallback("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("cleans up connection on heartbeat failure", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetInt", "discord.seq.testuser").Return(int64(0), false, errors.New("cache miss"))
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Connection lost").Return(nil)
|
||||
host.CacheMock.On("Remove", "discord.seq.testuser").Return(nil)
|
||||
|
||||
err := r.handleHeartbeatCallback("testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("connection cleaned up"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("handleClearActivityCallback", func() {
|
||||
It("clears activity and disconnects", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
|
||||
})).Return(nil)
|
||||
host.SchedulerMock.On("CancelSchedule", "testuser").Return(nil)
|
||||
host.WebSocketMock.On("CloseConnection", "testuser", int32(1000), "Navidrome disconnect").Return(nil)
|
||||
|
||||
err := r.handleClearActivityCallback("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WebSocket callbacks", func() {
|
||||
Describe("OnTextMessage", func() {
|
||||
It("handles valid JSON message", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("SetInt", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
err := r.OnTextMessage(websocket.OnTextMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Message: `{"s":42}`,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid JSON", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnTextMessage(websocket.OnTextMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Message: `not json`,
|
||||
})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnBinaryMessage", func() {
|
||||
It("handles binary message without error", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnBinaryMessage(websocket.OnBinaryMessageRequest{
|
||||
ConnectionID: "testuser",
|
||||
Data: "AQID", // base64 encoded [0x01, 0x02, 0x03]
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnError", func() {
|
||||
It("handles error without returning error", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnError(websocket.OnErrorRequest{
|
||||
ConnectionID: "testuser",
|
||||
Error: "test error",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("OnClose", func() {
|
||||
It("handles close without returning error", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
err := r.OnClose(websocket.OnCloseRequest{
|
||||
ConnectionID: "testuser",
|
||||
Code: 1000,
|
||||
Reason: "normal close",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sendActivity", func() {
|
||||
BeforeEach(func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.CacheMock.On("GetString", mock.MatchedBy(func(key string) bool {
|
||||
return strings.HasPrefix(key, "discord.image.")
|
||||
})).Return("", false, nil)
|
||||
host.CacheMock.On("SetString", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// Mock HTTP request for Discord external assets API (image processing)
|
||||
// When processImage is called, it makes an HTTP request
|
||||
httpReq := &pdk.HTTPRequest{}
|
||||
pdk.PDKMock.On("NewHTTPRequest", pdk.MethodPost, mock.Anything).Return(httpReq)
|
||||
pdk.PDKMock.On("Send", mock.Anything).Return(pdk.NewStubHTTPResponse(200, nil, []byte(`{"key":"test-key"}`)))
|
||||
})
|
||||
|
||||
It("sends activity update to Discord", func() {
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) &&
|
||||
strings.Contains(msg, `"name":"Test Song"`) &&
|
||||
strings.Contains(msg, `"state":"Test Artist"`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.sendActivity("client123", "testuser", "token123", activity{
|
||||
Application: "client123",
|
||||
Name: "Test Song",
|
||||
Type: 2,
|
||||
State: "Test Artist",
|
||||
Details: "Test Album",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("clearActivity", func() {
|
||||
It("sends presence update with nil activities", func() {
|
||||
pdk.PDKMock.On("Log", mock.Anything, mock.Anything).Maybe()
|
||||
host.WebSocketMock.On("SendText", "testuser", mock.MatchedBy(func(msg string) bool {
|
||||
return strings.Contains(msg, `"op":3`) && strings.Contains(msg, `"activities":null`)
|
||||
})).Return(nil)
|
||||
|
||||
err := r.clearActivity("testuser")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -324,105 +324,52 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) {
|
||||
// invokeWebSocketCallback is a generic helper that handles the common callback invocation pattern.
|
||||
func invokeWebSocketCallback[I any](ctx context.Context, s *webSocketServiceImpl, funcName string, input I, callbackName string, connectionID string) {
|
||||
instance := s.getPluginInstance()
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
|
||||
input := capabilities.OnTextMessageRequest{
|
||||
ConnectionID: connectionID,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
// Create a timeout context for this callback invocation
|
||||
callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnTextMessage, input)
|
||||
err := callPluginFunctionNoOutput(callbackCtx, instance, funcName, input)
|
||||
if err != nil {
|
||||
// Don't log error if function simply doesn't exist (optional callback)
|
||||
if !errors.Is(errFunctionNotFound, err) {
|
||||
log.Error(ctx, "WebSocket text message callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
|
||||
log.Error(ctx, "WebSocket "+callbackName+" callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) {
|
||||
invokeWebSocketCallback(ctx, s, FuncWebSocketOnTextMessage, capabilities.OnTextMessageRequest{
|
||||
ConnectionID: connectionID,
|
||||
Message: message,
|
||||
}, "text message", connectionID)
|
||||
}
|
||||
|
||||
func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) {
|
||||
instance := s.getPluginInstance()
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
|
||||
input := capabilities.OnBinaryMessageRequest{
|
||||
invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{
|
||||
ConnectionID: connectionID,
|
||||
Data: base64.StdEncoding.EncodeToString(data),
|
||||
}
|
||||
|
||||
// Create a timeout context for this callback invocation
|
||||
callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnBinaryMessage, input)
|
||||
if err != nil {
|
||||
// Don't log error if function simply doesn't exist (optional callback)
|
||||
if !errors.Is(errFunctionNotFound, err) {
|
||||
log.Error(ctx, "WebSocket binary message callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
|
||||
}
|
||||
}
|
||||
}, "binary message", connectionID)
|
||||
}
|
||||
|
||||
func (s *webSocketServiceImpl) invokeOnError(ctx context.Context, connectionID, errorMsg string) {
|
||||
instance := s.getPluginInstance()
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
|
||||
input := capabilities.OnErrorRequest{
|
||||
invokeWebSocketCallback(ctx, s, FuncWebSocketOnError, capabilities.OnErrorRequest{
|
||||
ConnectionID: connectionID,
|
||||
Error: errorMsg,
|
||||
}
|
||||
|
||||
// Create a timeout context for this callback invocation
|
||||
callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnError, input)
|
||||
if err != nil {
|
||||
// Don't log error if function simply doesn't exist (optional callback)
|
||||
if !errors.Is(errFunctionNotFound, err) {
|
||||
log.Error(ctx, "WebSocket error callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
|
||||
}
|
||||
}
|
||||
}, "error", connectionID)
|
||||
}
|
||||
|
||||
func (s *webSocketServiceImpl) invokeOnClose(ctx context.Context, connectionID string, code int32, reason string) {
|
||||
instance := s.getPluginInstance()
|
||||
if instance == nil {
|
||||
return
|
||||
}
|
||||
|
||||
input := capabilities.OnCloseRequest{
|
||||
invokeWebSocketCallback(ctx, s, FuncWebSocketOnClose, capabilities.OnCloseRequest{
|
||||
ConnectionID: connectionID,
|
||||
Code: code,
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
// Create a timeout context for this callback invocation
|
||||
callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnClose, input)
|
||||
if err != nil {
|
||||
// Don't log error if function simply doesn't exist (optional callback)
|
||||
if !errors.Is(errFunctionNotFound, err) {
|
||||
log.Error(ctx, "WebSocket close callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
|
||||
}
|
||||
}
|
||||
}, "close", connectionID)
|
||||
}
|
||||
|
||||
func (s *webSocketServiceImpl) getPluginInstance() *plugin {
|
||||
|
||||
@ -72,7 +72,9 @@ func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcN
|
||||
}
|
||||
if exit != 0 {
|
||||
if exit == notImplementedCode {
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start))
|
||||
// TODO Should we record metrics for not implemented calls?
|
||||
//plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds())
|
||||
return result, fmt.Errorf("%w: %s", errNotImplemented, funcName)
|
||||
}
|
||||
plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds())
|
||||
|
||||
@ -106,7 +106,7 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() {
|
||||
Expect(calls[0].ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("records metrics for not-implemented functions", func() {
|
||||
It("does not record metrics for not-implemented functions", func() {
|
||||
// Use partial metadata agent that doesn't implement GetArtistMBID
|
||||
partialRecorder := &mockMetricsRecorder{}
|
||||
partialManager, _ := createTestManagerWithPluginsAndMetrics(
|
||||
@ -123,9 +123,6 @@ var _ = Describe("callPluginFunction metrics", Ordered, func() {
|
||||
Expect(err).To(MatchError(errNotImplemented))
|
||||
|
||||
calls := partialRecorder.getCalls()
|
||||
Expect(calls).To(HaveLen(1))
|
||||
Expect(calls[0].plugin).To(Equal("partial-metadata-agent"))
|
||||
Expect(calls[0].method).To(Equal(FuncGetArtistMBID))
|
||||
Expect(calls[0].ok).To(BeFalse())
|
||||
Expect(calls).To(HaveLen(0))
|
||||
})
|
||||
})
|
||||
|
||||
@ -153,17 +153,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfigPermission": {
|
||||
"type": "object",
|
||||
"description": "Configuration access permissions for a plugin",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Explanation for why config access is needed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubsonicAPIPermission": {
|
||||
"type": "object",
|
||||
"description": "SubsonicAPI service permissions. Requires 'users' permission to be declared.",
|
||||
|
||||
@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configuration access permissions for a plugin
|
||||
type ConfigPermission struct {
|
||||
// Explanation for why config access is needed
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Experimental features that may change or be removed in future versions
|
||||
type Experimental struct {
|
||||
// Threads corresponds to the JSON schema field "threads".
|
||||
|
||||
@ -230,6 +230,7 @@ func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
|
||||
ID: s.ID,
|
||||
Name: s.Name,
|
||||
MBID: s.MBID,
|
||||
ISRC: s.ISRC,
|
||||
Artist: s.Artist,
|
||||
ArtistMBID: s.ArtistMBID,
|
||||
Album: s.Album,
|
||||
|
||||
@ -171,6 +171,8 @@ type SongRef struct {
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// ISRC is the International Standard Recording Code for the song.
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
// Artist is the artist name.
|
||||
Artist string `json:"artist,omitempty"`
|
||||
// ArtistMBID is the MusicBrainz artist ID.
|
||||
|
||||
@ -168,6 +168,8 @@ type SongRef struct {
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz ID for the song.
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// ISRC is the International Standard Recording Code for the song.
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
// Artist is the artist name.
|
||||
Artist string `json:"artist,omitempty"`
|
||||
// ArtistMBID is the MusicBrainz artist ID.
|
||||
|
||||
@ -242,6 +242,9 @@ pub struct SongRef {
|
||||
/// MBID is the MusicBrainz ID for the song.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub mbid: String,
|
||||
/// ISRC is the International Standard Recording Code for the song.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub isrc: String,
|
||||
/// Artist is the artist name.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub artist: String,
|
||||
|
||||
1
plugins/testdata/test-metadata-agent/main.go
vendored
1
plugins/testdata/test-metadata-agent/main.go
vendored
@ -134,6 +134,7 @@ func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsBy
|
||||
ID: "similar-track-id-" + strconv.Itoa(i+1),
|
||||
Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1),
|
||||
MBID: "similar-mbid-" + strconv.Itoa(i+1),
|
||||
ISRC: "similar-isrc-" + strconv.Itoa(i+1),
|
||||
Artist: input.Artist,
|
||||
ArtistMBID: "artist-mbid-" + strconv.Itoa(i+1),
|
||||
})
|
||||
|
||||
@ -19,6 +19,7 @@ builds:
|
||||
- linux_arm_v6
|
||||
- linux_arm_v7
|
||||
- linux_arm64
|
||||
- linux_riscv64
|
||||
- windows_386
|
||||
- windows_amd64
|
||||
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Битова дълбочина",
|
||||
"sampleRate": "",
|
||||
"missing": "Липсва",
|
||||
"libraryName": ""
|
||||
"libraryName": "",
|
||||
"composer": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Пусни по-късно",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Свали",
|
||||
"playNext": "Следваща",
|
||||
"info": "Информация",
|
||||
"showInPlaylist": ""
|
||||
"showInPlaylist": "",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -302,7 +304,7 @@
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": "",
|
||||
"quickScan": "",
|
||||
"quickScan": "Quick Scan",
|
||||
"fullScan": ""
|
||||
},
|
||||
"notifications": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "",
|
||||
"noLibrariesAssigned": ""
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"version": "",
|
||||
"author": "",
|
||||
"website": "",
|
||||
"permissions": "",
|
||||
"enabled": "",
|
||||
"status": "",
|
||||
"path": "",
|
||||
"lastError": "",
|
||||
"hasError": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"configKey": "",
|
||||
"configValue": "",
|
||||
"allUsers": "",
|
||||
"selectedUsers": "",
|
||||
"allLibraries": "",
|
||||
"selectedLibraries": ""
|
||||
},
|
||||
"sections": {
|
||||
"status": "",
|
||||
"info": "",
|
||||
"configuration": "",
|
||||
"manifest": "",
|
||||
"usersPermission": "",
|
||||
"libraryPermission": ""
|
||||
},
|
||||
"status": {
|
||||
"enabled": "",
|
||||
"disabled": ""
|
||||
},
|
||||
"actions": {
|
||||
"enable": "",
|
||||
"disable": "",
|
||||
"disabledDueToError": "",
|
||||
"disabledUsersRequired": "",
|
||||
"disabledLibrariesRequired": "",
|
||||
"addConfig": "",
|
||||
"rescan": ""
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "",
|
||||
"disabled": "",
|
||||
"updated": "",
|
||||
"error": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": ""
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"clickPermissions": "",
|
||||
"noConfig": "",
|
||||
"allUsersHelp": "",
|
||||
"noUsers": "",
|
||||
"permissionReason": "",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"noLibraries": "",
|
||||
"librariesRequired": "",
|
||||
"requiredHosts": "",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "",
|
||||
"configValue": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Премахни всички липсващи файлове",
|
||||
"remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
|
||||
"noSimilarSongsFound": "",
|
||||
"noTopSongsFound": ""
|
||||
"noTopSongsFound": "",
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Bittiefe",
|
||||
"sampleRate": "Samplerate",
|
||||
"missing": "Fehlend",
|
||||
"libraryName": "Bibliothek"
|
||||
"libraryName": "Bibliothek",
|
||||
"composer": "Komponist"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Später abspielen",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Herunterladen",
|
||||
"playNext": "Als nächstes abspielen",
|
||||
"info": "Mehr Informationen",
|
||||
"showInPlaylist": "In Wiedergabeliste anzeigen"
|
||||
"showInPlaylist": "In Wiedergabeliste anzeigen",
|
||||
"instantMix": "Sofort-Mix"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Bibliothek Scan läuft...",
|
||||
"noLibrariesAssigned": "Keine Bibliotheken zugeordnet"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"version": "Version",
|
||||
"author": "Autor",
|
||||
"website": "Website",
|
||||
"permissions": "Berechtigungen",
|
||||
"enabled": "Aktiv",
|
||||
"status": "Status",
|
||||
"path": "Pfad",
|
||||
"lastError": "Fehler",
|
||||
"hasError": "Fehler",
|
||||
"updatedAt": "Aktualisiert am",
|
||||
"createdAt": "Installiert",
|
||||
"configKey": "Schlüssel",
|
||||
"configValue": "Wert",
|
||||
"allUsers": "Alle Benutzer",
|
||||
"selectedUsers": "Ausgewählte Benutzer",
|
||||
"allLibraries": "Alle Bibliotheken",
|
||||
"selectedLibraries": "Ausgewählte Bibliotheken"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Plugin Information",
|
||||
"configuration": "Konfiguration",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Benutzer Zugriff",
|
||||
"libraryPermission": "Bibliotheken Zugriff"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aktiv",
|
||||
"disabled": "Inaktiv"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"disabledDueToError": "Fehler beheben um Plugin zu aktivieren",
|
||||
"disabledUsersRequired": "Wähle Benutzer Zugriff um Plugin zu aktivieren",
|
||||
"disabledLibrariesRequired": "Wähle Bibliotheken Zugriff um Plugin zu aktivieren",
|
||||
"addConfig": "Konfiguration hinzufügen",
|
||||
"rescan": "Scan"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugin aktiv",
|
||||
"disabled": "Plugin inaktiv",
|
||||
"updated": "Plugin aktualisiert",
|
||||
"error": "Fehler beim aktualisieren des Plugins"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfiguration muss valides JSON sein"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.",
|
||||
"clickPermissions": "Berechtigung anklicken für mehr Details",
|
||||
"noConfig": "Keine Konfiguration gesetzt",
|
||||
"allUsersHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Benutzer, inklusive solcher, die in Zukunft erstellt werden.",
|
||||
"noUsers": "Keine Benutzer ausgewählt",
|
||||
"permissionReason": "Begründung",
|
||||
"usersRequired": "Dieses Plugin benötigt Zugriff auf Benutzerinformationen. Wähle aus, auf welche Nutzer das Plugin zugreifen darf oder wähle 'Alle Benutzer'.",
|
||||
"allLibrariesHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Bibliotheken, inklusive solcher, die in Zukunft erstellt werden.",
|
||||
"noLibraries": "Keine Bibliotheken ausgewählt",
|
||||
"librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.",
|
||||
"requiredHosts": "Benötigte Hosts",
|
||||
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
|
||||
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Schlüssel",
|
||||
"configValue": "Wert"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
|
||||
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
|
||||
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
|
||||
"noTopSongsFound": "Keine beliebten Titel gefunden"
|
||||
"noTopSongsFound": "Keine beliebten Titel gefunden",
|
||||
"startingInstantMix": "Lade Sofort-Mix..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothek",
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Λίγο βάθος",
|
||||
"sampleRate": "Ποσοστό δειγματοληψίας",
|
||||
"missing": "Απών",
|
||||
"libraryName": "Βιβλιοθήκη"
|
||||
"libraryName": "Βιβλιοθήκη",
|
||||
"composer": "Συνθέτης"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Αναπαραγωγη Μετα",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Ληψη",
|
||||
"playNext": "Επόμενη Αναπαραγωγή",
|
||||
"info": "Εμφάνιση Πληροφοριών",
|
||||
"showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής"
|
||||
"showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής",
|
||||
"instantMix": "Άμεση Μίξη"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Σάρωση σε εξέλιξη...",
|
||||
"noLibrariesAssigned": "Δεν έχουν αντιστοιχιστεί βιβλιοθήκες σε αυτόν τον χρήστη"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Πρόσθετο |||| Πρόσθετα",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Όνομα",
|
||||
"description": "Περιγραφή",
|
||||
"version": "Έκδοση",
|
||||
"author": "Καλλιτέχνης",
|
||||
"website": "Ιστοσελίδα",
|
||||
"permissions": "Άδειες",
|
||||
"enabled": "Ενεργό",
|
||||
"status": "Κατάσταση",
|
||||
"path": "Διαδρομή",
|
||||
"lastError": "Σφάλμα",
|
||||
"hasError": "Σφάλμα",
|
||||
"updatedAt": "Ενημερώθηκε",
|
||||
"createdAt": "Εγκατασταθηκε",
|
||||
"configKey": "Κλειδί",
|
||||
"configValue": "Τιμή",
|
||||
"allUsers": "Επιτρέψτε όλους τους χρήστες",
|
||||
"selectedUsers": "Επιλογή χρηστών",
|
||||
"allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
|
||||
"selectedLibraries": "Επιλεγμένες βιβλιοθήκες"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Κατάσταση",
|
||||
"info": "Πληροφορίες Πρόσθετου",
|
||||
"configuration": "Παραμετροποίηση",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Άδειες Χρηστών",
|
||||
"libraryPermission": "Άδειες Βιβλιοθηκών"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Ενεργό",
|
||||
"disabled": "Ανενεργό"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Ενεργοποίηση",
|
||||
"disable": "Απενεργοποίηση",
|
||||
"disabledDueToError": "Διορθώστε το σφάλμα πριν την ενεργοποίηση",
|
||||
"disabledUsersRequired": "Επιλέξτε χρήστες πριν την ενεργοποίηση",
|
||||
"disabledLibrariesRequired": "Επιλέξτε βιβλιοθήκες πριν την ενεργοποίηση",
|
||||
"addConfig": "Προσθήκη παραμετροποίησης",
|
||||
"rescan": "Σάρωση ξανά"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Πρόσθετο ενεργοποιημένο",
|
||||
"disabled": "Πρόσθετο απενεργοποιημένο",
|
||||
"updated": "Πρόσθετο ενημερωμένο",
|
||||
"error": "Σφάλμα κατά την ενημέρωση του πρόσθετου"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Η παραμετροποίηση πρέπει να είναι συμβατό JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Παραμετροποιήστε το πρόσθετο με χρήση ζεύγων κλειδιών-τιμών. Αφήστε κενό αν το πρόσθετο δεν απαιτεί παραμετροποίηση",
|
||||
"clickPermissions": "Κάνετε κλικ για λεπτομέρειες αδειών",
|
||||
"noConfig": "Δεν ορίστηκε παραμετροποίηση",
|
||||
"allUsersHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλους τους χρήστες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.",
|
||||
"noUsers": "Δεν επιλέχθηκαν χρήστες",
|
||||
"permissionReason": "Αιτία",
|
||||
"usersRequired": "Το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες χρηστών. Ορίστε τους χρήστες που θα έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλους τους χρήστες'",
|
||||
"allLibrariesHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλες τις βιβλιοθήκες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.",
|
||||
"noLibraries": "Δεν επιλέχθηκαν βιβλιοθήκες",
|
||||
"librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'",
|
||||
"requiredHosts": "Απαιτούμενοι hosts",
|
||||
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
|
||||
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "κλειδί",
|
||||
"configValue": "τιμή"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
|
||||
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.",
|
||||
"noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια",
|
||||
"noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια"
|
||||
"noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια",
|
||||
"startingInstantMix": "Φόρτωση Άμεσης Μίξης..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Βιβλιοθήκη",
|
||||
|
||||
@ -12,16 +12,12 @@
|
||||
"artist": "Artista",
|
||||
"album": "Álbum",
|
||||
"path": "Ruta del archivo",
|
||||
"libraryName": "Biblioteca",
|
||||
"genre": "Género",
|
||||
"compilation": "Compilación",
|
||||
"year": "Año",
|
||||
"size": "Tamaño del archivo",
|
||||
"updatedAt": "Actualizado el",
|
||||
"bitRate": "Tasa de bits",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"channels": "Canales",
|
||||
"discSubtitle": "Subtítulo del disco",
|
||||
"starred": "Favorito",
|
||||
"comment": "Comentario",
|
||||
@ -29,6 +25,7 @@
|
||||
"quality": "Calidad",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Últimas reproducciones",
|
||||
"channels": "Canales",
|
||||
"createdAt": "Creado el",
|
||||
"grouping": "Agrupación",
|
||||
"mood": "Estado de ánimo",
|
||||
@ -36,17 +33,22 @@
|
||||
"tags": "Etiquetas",
|
||||
"mappedTags": "Etiquetas asignadas",
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"missing": "Faltante"
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"missing": "Faltante",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Compositor"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Reproducir después",
|
||||
"playNow": "Reproducir ahora",
|
||||
"addToPlaylist": "Agregar a la playlist",
|
||||
"showInPlaylist": "Mostrar en la lista de reproducción",
|
||||
"shuffleAll": "Todas aleatorias",
|
||||
"download": "Descarga",
|
||||
"playNext": "Siguiente",
|
||||
"info": "Obtener información"
|
||||
"info": "Obtener información",
|
||||
"showInPlaylist": "Mostrar en la lista de reproducción",
|
||||
"instantMix": "Mezcla instantánea"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -57,38 +59,38 @@
|
||||
"duration": "Duración",
|
||||
"songCount": "Canciones",
|
||||
"playCount": "Reproducciones",
|
||||
"size": "Tamaño del archivo",
|
||||
"name": "Nombre",
|
||||
"libraryName": "Biblioteca",
|
||||
"genre": "Género",
|
||||
"compilation": "Compilación",
|
||||
"year": "Año",
|
||||
"date": "Fecha de grabación",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Publicado",
|
||||
"releases": "Lanzamiento |||| Lanzamientos",
|
||||
"released": "Publicado",
|
||||
"updatedAt": "Actualizado el",
|
||||
"comment": "Comentario",
|
||||
"rating": "Calificación",
|
||||
"createdAt": "Creado el",
|
||||
"size": "Tamaño del archivo",
|
||||
"originalDate": "Original",
|
||||
"releaseDate": "Publicado",
|
||||
"releases": "Lanzamiento |||| Lanzamientos",
|
||||
"released": "Publicado",
|
||||
"recordLabel": "Discográfica",
|
||||
"catalogNum": "Número de catálogo",
|
||||
"releaseType": "Tipo de lanzamiento",
|
||||
"grouping": "Agrupación",
|
||||
"media": "Medios",
|
||||
"mood": "Estado de ánimo",
|
||||
"missing": "Faltante"
|
||||
"date": "Fecha de grabación",
|
||||
"missing": "Faltante",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Reproducir",
|
||||
"playNext": "Reproducir siguiente",
|
||||
"addToQueue": "Reproducir después",
|
||||
"share": "Compartir",
|
||||
"shuffle": "Aleatorio",
|
||||
"addToPlaylist": "Agregar a la lista",
|
||||
"download": "Descargar",
|
||||
"info": "Obtener información"
|
||||
"info": "Obtener información",
|
||||
"share": "Compartir"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Todos",
|
||||
@ -106,10 +108,10 @@
|
||||
"name": "Nombre",
|
||||
"albumCount": "Número de álbumes",
|
||||
"songCount": "Número de canciones",
|
||||
"size": "Tamaño",
|
||||
"playCount": "Reproducciones",
|
||||
"rating": "Calificación",
|
||||
"genre": "Género",
|
||||
"size": "Tamaño",
|
||||
"role": "Rol",
|
||||
"missing": "Faltante"
|
||||
},
|
||||
@ -130,9 +132,9 @@
|
||||
"maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Más destacadas",
|
||||
"shuffle": "Aleatorio",
|
||||
"radio": "Radio"
|
||||
"radio": "Radio",
|
||||
"topSongs": "Más destacadas"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@ -141,7 +143,6 @@
|
||||
"userName": "Nombre de usuario",
|
||||
"isAdmin": "Es administrador",
|
||||
"lastLoginAt": "Último inicio de sesión",
|
||||
"lastAccessAt": "Último acceso",
|
||||
"updatedAt": "Actualizado el",
|
||||
"name": "Nombre",
|
||||
"password": "Contraseña",
|
||||
@ -150,6 +151,7 @@
|
||||
"currentPassword": "Contraseña actual",
|
||||
"newPassword": "Nueva contraseña",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Último acceso",
|
||||
"libraries": "Bibliotecas"
|
||||
},
|
||||
"helperTexts": {
|
||||
@ -211,9 +213,9 @@
|
||||
"selectPlaylist": "Seleccione una lista:",
|
||||
"addNewPlaylist": "Creada \"%{name}\"",
|
||||
"export": "Exportar",
|
||||
"saveQueue": "Guardar la fila de reproducción en una playlist",
|
||||
"makePublic": "Hazla pública",
|
||||
"makePrivate": "Hazla privada",
|
||||
"saveQueue": "Guardar la fila de reproducción en una playlist",
|
||||
"searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…",
|
||||
"pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción",
|
||||
"removeFromSelection": "Quitar de la selección"
|
||||
@ -244,7 +246,6 @@
|
||||
"username": "Compartido por",
|
||||
"url": "URL",
|
||||
"description": "Descripción",
|
||||
"downloadable": "¿Permitir descargas?",
|
||||
"contents": "Contenido",
|
||||
"expiresAt": "Caduca el",
|
||||
"lastVisitedAt": "Visitado por última vez el",
|
||||
@ -252,14 +253,12 @@
|
||||
"format": "Formato",
|
||||
"maxBitRate": "Tasa de bits Máx.",
|
||||
"updatedAt": "Actualizado el",
|
||||
"createdAt": "Creado el"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"createdAt": "Creado el",
|
||||
"downloadable": "¿Permitir descargas?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Fichero faltante |||| Ficheros faltantes",
|
||||
"empty": "No faltan archivos",
|
||||
"fields": {
|
||||
"path": "Ruta",
|
||||
"size": "Tamaño",
|
||||
@ -272,7 +271,8 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eliminado"
|
||||
}
|
||||
},
|
||||
"empty": "No faltan archivos"
|
||||
},
|
||||
"library": {
|
||||
"name": "Biblioteca |||| Bibliotecas",
|
||||
@ -302,20 +302,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Escanear biblioteca",
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"manageUsers": "Gestionar el acceso de usarios",
|
||||
"viewDetails": "Ver detalles"
|
||||
"viewDetails": "Ver detalles",
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "La biblioteca se creó correctamente",
|
||||
"updated": "La biblioteca se actualizó correctamente",
|
||||
"deleted": "La biblioteca se eliminó correctamente",
|
||||
"scanStarted": "El escaneo de la biblioteca ha comenzado",
|
||||
"scanCompleted": "El escaneo de la biblioteca se completó",
|
||||
"quickScanStarted": "Escaneo rápido ha comenzado",
|
||||
"fullScanStarted": "Escaneo completo ha comenzado",
|
||||
"scanError": "Error al iniciar el escaneo. Revisa los registros",
|
||||
"scanCompleted": "El escaneo de la biblioteca se completó"
|
||||
"scanError": "Error al iniciar el escaneo. Revisa los registros"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "El nombre de la biblioteca es obligatorio",
|
||||
@ -396,7 +396,9 @@
|
||||
"allLibrariesHelp": "Cuando se active, el plugin tendrá acceso a todas las bibliotecas, incluidas las que se creen en el futuro.",
|
||||
"noLibraries": "Ninguna biblioteca seleccionada",
|
||||
"librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.",
|
||||
"requiredHosts": "Hosts requeridos"
|
||||
"requiredHosts": "Hosts requeridos",
|
||||
"configValidationError": "La validación de la configuración falló:",
|
||||
"schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "clave",
|
||||
@ -439,7 +441,6 @@
|
||||
"add": "Añadir",
|
||||
"back": "Ir atrás",
|
||||
"bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"cancel": "Cancelar",
|
||||
"clear_input_value": "Limpiar valor",
|
||||
"clone": "Duplicar",
|
||||
@ -463,6 +464,7 @@
|
||||
"close_menu": "Cerrar menú",
|
||||
"unselect": "Deseleccionado",
|
||||
"skip": "Omitir",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Compartir",
|
||||
"download": "Descargar"
|
||||
},
|
||||
@ -554,47 +556,42 @@
|
||||
"transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.",
|
||||
"transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.",
|
||||
"songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista",
|
||||
"noSimilarSongsFound": "No se encontraron canciones similares",
|
||||
"noTopSongsFound": "No se encontraron canciones destacadas",
|
||||
"noPlaylistsAvailable": "Ninguna lista disponible",
|
||||
"delete_user_title": "Eliminar usuario '%{name}'",
|
||||
"delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?",
|
||||
"remove_missing_title": "Eliminar archivos faltantes",
|
||||
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"remove_all_missing_title": "Eliminar todos los archivos faltantes",
|
||||
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"notifications_blocked": "Las notificaciones de este sitio están bloqueadas en tu navegador",
|
||||
"notifications_not_available": "Este navegador no soporta notificaciones o no ingresaste a Navidrome usando https",
|
||||
"lastfmLinkSuccess": "Last.fm esta conectado y el scrobbling esta activado",
|
||||
"lastfmLinkFailure": "No se pudo conectar con Last.fm",
|
||||
"lastfmUnlinkSuccess": "Last.fm se ha desconectado y el scrobbling se desactivo",
|
||||
"lastfmUnlinkFailure": "No se pudo desconectar Last.fm",
|
||||
"listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}",
|
||||
"listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling",
|
||||
"listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
|
||||
"openIn": {
|
||||
"lastfm": "Ver en Last.fm",
|
||||
"musicbrainz": "Ver en MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Leer más...",
|
||||
"listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}",
|
||||
"listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling",
|
||||
"listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
|
||||
"downloadOriginalFormat": "Descargar formato original",
|
||||
"shareOriginalFormat": "Compartir formato original",
|
||||
"shareDialogTitle": "Compartir %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
|
||||
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
|
||||
"shareSuccess": "URL copiada al portapapeles: %{url}",
|
||||
"shareFailure": "Error al copiar la URL %{url} al portapapeles",
|
||||
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
|
||||
"downloadOriginalFormat": "Descargar formato original"
|
||||
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
|
||||
"remove_missing_title": "Eliminar archivos faltantes",
|
||||
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"remove_all_missing_title": "Eliminar todos los archivos faltantes",
|
||||
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
|
||||
"noSimilarSongsFound": "No se encontraron canciones similares",
|
||||
"noTopSongsFound": "No se encontraron canciones destacadas",
|
||||
"startingInstantMix": "Cargando la mezcla instantánea..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas las bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} bibliotecas",
|
||||
"selectLibraries": "Seleccionar bibliotecas",
|
||||
"none": "Ninguno"
|
||||
},
|
||||
"settings": "Ajustes",
|
||||
"version": "Versión",
|
||||
"theme": "Tema",
|
||||
@ -605,7 +602,6 @@
|
||||
"language": "Idioma",
|
||||
"defaultView": "Vista por defecto",
|
||||
"desktop_notifications": "Notificaciones de escritorio",
|
||||
"lastfmNotConfigured": "La clave API de Last.fm no está configurada",
|
||||
"lastfmScrobbling": "Scrobble a Last.fm",
|
||||
"listenBrainzScrobbling": "Scrobble a ListenBrainz",
|
||||
"replaygain": "Modo de ReplayGain",
|
||||
@ -614,13 +610,20 @@
|
||||
"none": "Desactivado",
|
||||
"album": "Ganancia del álbum",
|
||||
"track": "Ganancia de pista"
|
||||
}
|
||||
},
|
||||
"lastfmNotConfigured": "La clave API de Last.fm no está configurada"
|
||||
}
|
||||
},
|
||||
"albumList": "Álbumes",
|
||||
"about": "Acerca de",
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Playlists Compartidas",
|
||||
"about": "Acerca de"
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas las bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} bibliotecas",
|
||||
"selectLibraries": "Seleccionar bibliotecas",
|
||||
"none": "Ninguno"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de reproducción",
|
||||
@ -679,17 +682,12 @@
|
||||
"totalScanned": "Total de carpetas escaneadas",
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo",
|
||||
"selectiveScan": "Selectivo",
|
||||
"serverUptime": "Uptime del servidor",
|
||||
"serverDown": "OFFLINE",
|
||||
"scanType": "Tipo",
|
||||
"status": "Error de escaneo",
|
||||
"elapsedTime": "Tiempo transcurrido"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "En reproducción",
|
||||
"empty": "Nada en reproducción",
|
||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||
"elapsedTime": "Tiempo transcurrido",
|
||||
"selectiveScan": "Selectivo"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atajos de teclado de Navidrome",
|
||||
@ -699,10 +697,15 @@
|
||||
"toggle_play": "Reproducir / Pausar",
|
||||
"prev_song": "Canción anterior",
|
||||
"next_song": "Siguiente canción",
|
||||
"current_song": "Canción actual",
|
||||
"vol_up": "Subir volumen",
|
||||
"vol_down": "Bajar volumen",
|
||||
"toggle_love": "Marca esta canción como favorita"
|
||||
"toggle_love": "Marca esta canción como favorita",
|
||||
"current_song": "Canción actual"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "En reproducción",
|
||||
"empty": "Nada en reproducción",
|
||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Bittisyvyys",
|
||||
"sampleRate": "Näytteenottotaajuus",
|
||||
"missing": "Puuttuva",
|
||||
"libraryName": "Kirjasto"
|
||||
"libraryName": "Kirjasto",
|
||||
"composer": "Säveltäjä"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lisää jonoon",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Lataa",
|
||||
"playNext": "Soita seuraavaksi",
|
||||
"info": "Info",
|
||||
"showInPlaylist": "Näytä soittolistassa"
|
||||
"showInPlaylist": "Näytä soittolistassa",
|
||||
"instantMix": "Pikasekoitus"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Skannaus käynnissä...",
|
||||
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Liitännäinen |||| Liitännäiset",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Nimi",
|
||||
"description": "Kuvaus",
|
||||
"version": "Versio",
|
||||
"author": "Tekijä",
|
||||
"website": "Verkkosivusto",
|
||||
"permissions": "Oikeudet",
|
||||
"enabled": "Käytössä",
|
||||
"status": "Tila",
|
||||
"path": "Polku",
|
||||
"lastError": "Virhe",
|
||||
"hasError": "Virhe",
|
||||
"updatedAt": "Päivitetty",
|
||||
"createdAt": "Asennettu",
|
||||
"configKey": "Avain",
|
||||
"configValue": "Arvo",
|
||||
"allUsers": "Salli kaikki käyttäjät",
|
||||
"selectedUsers": "Valitut käyttäjät",
|
||||
"allLibraries": "Salli kaikki kirjastot",
|
||||
"selectedLibraries": "Valitut kirjastot"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Tila",
|
||||
"info": "Lisäosan tiedot",
|
||||
"configuration": "Määritykset",
|
||||
"manifest": "Luettelo",
|
||||
"usersPermission": "Käyttäjäoikeudet",
|
||||
"libraryPermission": "Kirjaston oikeudet"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Käytössä",
|
||||
"disabled": "Ei käytössä"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Ota käyttöön",
|
||||
"disable": "Poista käytöstä",
|
||||
"disabledDueToError": "Korjaa virhe ennen käyttöönottoa",
|
||||
"disabledUsersRequired": "Valitse käyttäjät ennen käyttöönottoa",
|
||||
"disabledLibrariesRequired": "Valitse kirjastot ennen käyttöönottoa",
|
||||
"addConfig": "Lisää määritykset",
|
||||
"rescan": "Skannaa uudelleen"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Lisäosa käytössä",
|
||||
"disabled": "Lisäosa ei käytössä",
|
||||
"updated": "Lisäosa päivitetty",
|
||||
"error": "Virhe lisäosaa päivitettäessä"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Määrityksen on oltava kelvollinen JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Määritä lisäosa avain-arvo-parien avulla. Jätä tyhjäksi, jos lisäosa ei vaadi määrityksiä.",
|
||||
"clickPermissions": "Napsauta käyttöoikeutta saadaksesi lisätietoja",
|
||||
"noConfig": "Ei määritettyjä asetuksia",
|
||||
"allUsersHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin käyttäjiin, myös tulevaisuudessa luotaviin.",
|
||||
"noUsers": "Ei valittuja käyttäjiä",
|
||||
"permissionReason": "Syy",
|
||||
"usersRequired": "Tämä laajennus vaatii pääsyn käyttäjätietoihin. Valitse käyttäjät, joihin laajennus voi päästä, tai ota käyttöön 'Salli kaikki käyttäjät'.",
|
||||
"allLibrariesHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin kirjastoihin, myös tulevaisuudessa luotaviin.",
|
||||
"noLibraries": "Ei valittuja kirjastoja",
|
||||
"librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.",
|
||||
"requiredHosts": "Vaaditut palvelimet",
|
||||
"configValidationError": "Määrityksen validointi epäonnistui:",
|
||||
"schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "avain",
|
||||
"configValue": "arvo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Poista kaikki puuttuvat tiedostot",
|
||||
"remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.",
|
||||
"noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt",
|
||||
"noTopSongsFound": "Suosituimpia kappaleita ei löytynyt"
|
||||
"noTopSongsFound": "Suosituimpia kappaleita ei löytynyt",
|
||||
"startingInstantMix": "Ladataan Pikasekoitus..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Kirjasto",
|
||||
@ -586,16 +663,16 @@
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Tietoja",
|
||||
"config": "Kokoonpano"
|
||||
"config": "Määritykset"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Konfiguraation nimi",
|
||||
"environmentVariable": "Ympäristömuuttuja",
|
||||
"currentValue": "Nykyinen arvo",
|
||||
"configurationFile": "Konfiguraatiotiedosto",
|
||||
"exportToml": "Vie konfiguraatio (TOML)",
|
||||
"exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa",
|
||||
"exportFailed": "Konfiguraation kopiointi epäonnistui",
|
||||
"configurationFile": "Määritystiedosto",
|
||||
"exportToml": "Vie määritys (TOML)",
|
||||
"exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
|
||||
"exportFailed": "Määritysten kopiointi epäonnistui",
|
||||
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
|
||||
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
|
||||
}
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Profondeur de bits",
|
||||
"sampleRate": "Fréquence d'échantillonnage",
|
||||
"missing": "Manquant",
|
||||
"libraryName": "Bibliothèque"
|
||||
"libraryName": "Bibliothèque",
|
||||
"composer": "Compositeur·e"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ajouter à la file",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Télécharger",
|
||||
"playNext": "Jouer ensuite",
|
||||
"info": "Plus d'informations",
|
||||
"showInPlaylist": "Montrer dans la playlist"
|
||||
"showInPlaylist": "Montrer dans la playlist",
|
||||
"instantMix": "Mix instantanné"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Scan en cours...",
|
||||
"noLibrariesAssigned": "Aucune bibliothèque pour cet utilisateur"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Extension |||| Extensions",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Nom",
|
||||
"description": "Description",
|
||||
"version": "Version",
|
||||
"author": "Auteur.e",
|
||||
"website": "Site web",
|
||||
"permissions": "Permissions",
|
||||
"enabled": "Activée",
|
||||
"status": "Statut",
|
||||
"path": "Chemin",
|
||||
"lastError": "Erreur",
|
||||
"hasError": "Erreur",
|
||||
"updatedAt": "Mise à jour",
|
||||
"createdAt": "Installée",
|
||||
"configKey": "Clef",
|
||||
"configValue": "Valeur",
|
||||
"allUsers": "Autoriser tous les utilisateur·rices",
|
||||
"selectedUsers": "Utilisateur·rices sélectionné.e.s",
|
||||
"allLibraries": "Autoriser toutes les bibliothèques",
|
||||
"selectedLibraries": "Bibliothèques sélectionnées"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Statut",
|
||||
"info": "Informations de l'extension",
|
||||
"configuration": "Configuration",
|
||||
"manifest": "Manifeste",
|
||||
"usersPermission": "Permissions utilisateur·ices",
|
||||
"libraryPermission": "Permissions des bibliothèques"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Activées",
|
||||
"disabled": "Désactivées"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"disabledDueToError": "L'erreur doit être réglée avant de pouvoir activer la bibliothèque",
|
||||
"disabledUsersRequired": "Sélectionner des utilisateur·ices avant d'activer la bibliothèque",
|
||||
"disabledLibrariesRequired": "Sélectionner au moins une bibliothèque",
|
||||
"addConfig": "Ajouter une configuration",
|
||||
"rescan": "Rescanner"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Extension activée",
|
||||
"disabled": "Extension désactivée",
|
||||
"updated": "Extension mise à jour",
|
||||
"error": "Erreur pendant la mise à jour de l'extension"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "La configuration doit être un JSON valide"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Configurer l'extension en utilisant des paires clef/valeurs. Laisser vide si l'extension ne requiert aucune configuration.",
|
||||
"clickPermissions": "Cliquer sur une permission pour plus de détails",
|
||||
"noConfig": "Aucune configuration",
|
||||
"allUsersHelp": "Quand sélectionnée, l'extension aura accès à l'ensemble des utilisateur·rices, y compris ceux créé.e.s dans le future.",
|
||||
"noUsers": "Aucun.e utilisateur·rice sélectionné.e",
|
||||
"permissionReason": "Raison",
|
||||
"usersRequired": "Cette extension nécessite un accès aux informations utilisateurs. Sélectionnez les utilisateur·rices autorisé.e.s ou sélectionnez 'Tout autoriser'.",
|
||||
"allLibrariesHelp": "Quand sélectionnée, cette extension aura accès à l'ensemble des bibliothèques, y compris celles créées dans le futur.",
|
||||
"noLibraries": "Aucune bibliothèque sélectionnée",
|
||||
"librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.",
|
||||
"requiredHosts": "Hôtes requis",
|
||||
"configValidationError": "Erreur lors de la validation de la configuration",
|
||||
"schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "clef",
|
||||
"configValue": "valeur"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
|
||||
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.",
|
||||
"noSimilarSongsFound": "Aucun titre similaire n'a été trouvé",
|
||||
"noTopSongsFound": "Aucun meilleur titre n'a été trouvé"
|
||||
"noTopSongsFound": "Aucun meilleur titre n'a été trouvé",
|
||||
"startingInstantMix": "Chargement du mix instantanné..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliothèque",
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Calidade de Bit",
|
||||
"sampleRate": "Taxa de mostra",
|
||||
"missing": "Falta",
|
||||
"libraryName": "Biblioteca"
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Composición"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Ao final da cola",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Descargar",
|
||||
"playNext": "A continuación",
|
||||
"info": "Obter info",
|
||||
"showInPlaylist": "Mostrar en Lista de reprodución"
|
||||
"showInPlaylist": "Mostrar en Lista de reprodución",
|
||||
"instantMix": "Mestura Súbita"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Escaneo en progreso…",
|
||||
"noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Complemento |||| Complementos",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Nome",
|
||||
"description": "Descrición",
|
||||
"version": "Versión",
|
||||
"author": "Autoría",
|
||||
"website": "Sitio web",
|
||||
"permissions": "Permisos",
|
||||
"enabled": "Activado",
|
||||
"status": "Estado",
|
||||
"path": "Ruta",
|
||||
"lastError": "Erro",
|
||||
"hasError": "Erro",
|
||||
"updatedAt": "Actualizado",
|
||||
"createdAt": "Instalado",
|
||||
"configKey": "Clave",
|
||||
"configValue": "Valor",
|
||||
"allUsers": "Para todas as usuarias",
|
||||
"selectedUsers": "Usuarias seleccionadas",
|
||||
"allLibraries": "Permitir todas as bibliotecas",
|
||||
"selectedLibraries": "Selecciona bibliotecas"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Estado",
|
||||
"info": "Info do complemento",
|
||||
"configuration": "Configuración",
|
||||
"manifest": "Manifesto",
|
||||
"usersPermission": "Permiso sobre usuarias",
|
||||
"libraryPermission": "Permiso sobre bibliotecas"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Activado",
|
||||
"disabled": "Desactivado"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Activar",
|
||||
"disable": "Desactivar",
|
||||
"disabledDueToError": "Arranxar erro antes de activar",
|
||||
"disabledUsersRequired": "Selección de usuarias antes de activar",
|
||||
"disabledLibrariesRequired": "Selección de bibliotecas antes de activar",
|
||||
"addConfig": "Engadir configuración",
|
||||
"rescan": "Volver a escanear"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Complemento activado",
|
||||
"disabled": "Complemento desactivado",
|
||||
"updated": "Complemento actualizado",
|
||||
"error": "Erro ao actualizar o complemento"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "A configuración debe ser un JSON válido"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Configura o complemento usando pares clave-valor. Deixa baleiro se o complemento non require configuración.",
|
||||
"clickPermissions": "Preme nun permiso para ver detalles",
|
||||
"noConfig": "Sen configuración establecida",
|
||||
"allUsersHelp": "Ao activalo, o complemento terá acceso a todas as usuarias, incluíndo aquelas que se creen no futuro.",
|
||||
"noUsers": "Sen usuarias seleccionadas",
|
||||
"permissionReason": "Motivo",
|
||||
"usersRequired": "O complemento precisa acceso á información sobre a usuaria. Selecciona as usuarias ás que pode acceder, ou activa 'Todas as usuarias'.",
|
||||
"allLibrariesHelp": "Ao activalo, o complemento terá acceso a todas as bibliotecas, incluíndo aquelas que se creen no futuro.",
|
||||
"noLibraries": "Sen bibliotecas seleccionadas",
|
||||
"librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.",
|
||||
"requiredHosts": "Servidores requeridos",
|
||||
"configValidationError": "Fallou a comprobación da configuración:",
|
||||
"schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "clave",
|
||||
"configValue": "valor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Retirar todos os ficheiros que faltan",
|
||||
"remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
|
||||
"noSimilarSongsFound": "Sen cancións parecidas",
|
||||
"noTopSongsFound": "Sen cancións destacadas"
|
||||
"noTopSongsFound": "Sen cancións destacadas",
|
||||
"startingInstantMix": "Cargando Mestura Súbita…"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "Sample rate",
|
||||
"missing": "Hilang",
|
||||
"libraryName": "Pustaka"
|
||||
"libraryName": "Pustaka",
|
||||
"composer": "Komposer"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Tambah ke antrean",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Unduh",
|
||||
"playNext": "Putar Berikutnya",
|
||||
"info": "Lihat Info",
|
||||
"showInPlaylist": "Tampilkan di Playlist"
|
||||
"showInPlaylist": "Tampilkan di Playlist",
|
||||
"instantMix": "Mix Instan"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -301,14 +303,19 @@
|
||||
"actions": {
|
||||
"scan": "Pindai Pustaka",
|
||||
"manageUsers": "Kelola Akses Pengguna",
|
||||
"viewDetails": "Lihat Detail"
|
||||
"viewDetails": "Lihat Detail",
|
||||
"quickScan": "Pindai Cepat",
|
||||
"fullScan": "Pindai Keseluruhan"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Pustaka berhasil dibuat",
|
||||
"updated": "Pustaka berhasil dibuat",
|
||||
"deleted": "Berhasil menghapus pustaka",
|
||||
"scanStarted": "Memindai pustaka dimulai",
|
||||
"scanCompleted": "Memindai pustaka selesai"
|
||||
"scanCompleted": "Memindai pustaka selesai",
|
||||
"quickScanStarted": "Pemindaian cepat dimulai",
|
||||
"fullScanStarted": "Pemindaian keseluruhan dimulai",
|
||||
"scanError": "Kesalahan saat memulai pemindaian. Periksa log"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Nama pustaka diperlukan",
|
||||
@ -323,6 +330,80 @@
|
||||
"scanInProgress": "Pemindaian sedang berlangsung...",
|
||||
"noLibrariesAssigned": "Tidak ada pustaka yang ditugaskan ke pengguna ini"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugin",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Nama",
|
||||
"description": "Deskripsi",
|
||||
"version": "Versi",
|
||||
"author": "Pembuat",
|
||||
"website": "Situs Web",
|
||||
"permissions": "Perizinan",
|
||||
"enabled": "Diaktifkan",
|
||||
"status": "Status",
|
||||
"path": "Jalur",
|
||||
"lastError": "Kesalahan",
|
||||
"hasError": "Kesalahan",
|
||||
"updatedAt": "Diperbarui",
|
||||
"createdAt": "Terinstal",
|
||||
"configKey": "Key",
|
||||
"configValue": "Value",
|
||||
"allUsers": "Izinkan semua pengguna",
|
||||
"selectedUsers": "Pengguna yang dipilih",
|
||||
"allLibraries": "Izinkan semua pustaka",
|
||||
"selectedLibraries": "Pustaka dipilih"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Informasi plugin",
|
||||
"configuration": "Konfigurasi",
|
||||
"manifest": "Manifes",
|
||||
"usersPermission": "Pengguna yang Diizinkan",
|
||||
"libraryPermission": "Pustaka yang Diizinkan"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Diaktifkan",
|
||||
"disabled": "Dinonaktifkan"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktifkan",
|
||||
"disable": "Nonaktifkan",
|
||||
"disabledDueToError": "Perbaiki kesalahan sebelum diaktifkan",
|
||||
"disabledUsersRequired": "Pilih pengguna sebelum diaktifkan",
|
||||
"disabledLibrariesRequired": "Pilih pustaka sebelum diaktifkan",
|
||||
"addConfig": "Tambahkan Konfigurasi",
|
||||
"rescan": "Pindai ulang"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugin diaktifkan",
|
||||
"disabled": "Plugin dinonaktifkan",
|
||||
"updated": "Plugin diperbarui",
|
||||
"error": "Kesalahan saat memperbarui plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurasi harus berupa JSON yang valid"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfigurasikan plugin menggunakan key-value pairs. Biarkan kosong jika plugin tidak membutuhkan konfigurasi.",
|
||||
"clickPermissions": "Klik perizinan untuk detail",
|
||||
"noConfig": "Konfigurasi tidak diatur",
|
||||
"allUsersHelp": "Ketika diaktifkan, plugin akan mengakses untuk semua pengguna, termasuk yang akan dibuat di masa depan.",
|
||||
"noUsers": "Tidak ada pengguna yang dipilih",
|
||||
"permissionReason": "Alasan",
|
||||
"usersRequired": "Plugin ini membutuhkan akses ke informasi pengguna. Pilih pengguna yang bisa mengakses plugin, atau aktifkan 'Izinkan semua pengguna'.",
|
||||
"allLibrariesHelp": "Ketika diaktifkan, plugin akan memiliki akses ke semua pustaka, termasuk yang dibuat di masa depan.",
|
||||
"noLibraries": "Tidak ada pustaka yang dipilih",
|
||||
"librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.",
|
||||
"requiredHosts": "Hosts diperlukan",
|
||||
"configValidationError": "Validasi konfigurasi gagal:",
|
||||
"schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "key",
|
||||
"configValue": "value"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -506,7 +587,8 @@
|
||||
"remove_all_missing_title": "Hapus semua file yang hilang",
|
||||
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.",
|
||||
"noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan",
|
||||
"noTopSongsFound": "Tidak ada lagu teratas ditemukan"
|
||||
"noTopSongsFound": "Tidak ada lagu teratas ditemukan",
|
||||
"startingInstantMix": "Memuat Mix Instan..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Pustaka",
|
||||
@ -604,7 +686,8 @@
|
||||
"serverDown": "LURING",
|
||||
"scanType": "Tipe",
|
||||
"status": "Kesalahan Memindai",
|
||||
"elapsedTime": "Waktu Berakhir"
|
||||
"elapsedTime": "Waktu Berakhir",
|
||||
"selectiveScan": "Selektif"
|
||||
},
|
||||
"help": {
|
||||
"title": "Tombol Pintasan Navidrome",
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Bit diepte",
|
||||
"sampleRate": "Sample waarde",
|
||||
"missing": "Ontbrekend",
|
||||
"libraryName": "Bibliotheek"
|
||||
"libraryName": "Bibliotheek",
|
||||
"composer": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Voeg toe aan wachtrij",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Downloaden",
|
||||
"playNext": "Volgende",
|
||||
"info": "Meer info",
|
||||
"showInPlaylist": "Toon in afspeellijst"
|
||||
"showInPlaylist": "Toon in afspeellijst",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Scan is bezig...",
|
||||
"noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Naam",
|
||||
"description": "Omschrijving",
|
||||
"version": "Versie",
|
||||
"author": "Auteur",
|
||||
"website": "Website",
|
||||
"permissions": "Permissies",
|
||||
"enabled": "Aangezet",
|
||||
"status": "Status",
|
||||
"path": "Pad",
|
||||
"lastError": "Fout",
|
||||
"hasError": "Fout",
|
||||
"updatedAt": "Geupdate",
|
||||
"createdAt": "Geinstalleerd",
|
||||
"configKey": "Sleutel",
|
||||
"configValue": "Waarde",
|
||||
"allUsers": "Alle gebruikers toelaten",
|
||||
"selectedUsers": "Geselecteerde gebruikers",
|
||||
"allLibraries": "Alle bibliotheken toestaan",
|
||||
"selectedLibraries": "Geselecteerde bibliotheken"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Plugin informatie",
|
||||
"configuration": "Configuratie",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Gebruikers permissie",
|
||||
"libraryPermission": "Bibliotheekpermissie"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aangezet",
|
||||
"disabled": "Uitgezet"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aanzetten",
|
||||
"disable": "Uitzetten",
|
||||
"disabledDueToError": "Herstel de fout voor aanzetten",
|
||||
"disabledUsersRequired": "Selecteer gebruikers voor aanzetten",
|
||||
"disabledLibrariesRequired": "Selecteer bibliotheek voor aanzetten",
|
||||
"addConfig": "Configuratie toevoegen",
|
||||
"rescan": "Opnieuw scannen"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugin actief",
|
||||
"disabled": "Plugin niet actief",
|
||||
"updated": "Plugin geupdate",
|
||||
"error": "Fout bij updaten plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Configuratie moet geldige JSON zijn"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"clickPermissions": "Klik op permissie voor details",
|
||||
"noConfig": "Geen configuratie ingesteld",
|
||||
"allUsersHelp": "",
|
||||
"noUsers": "Geen gebruikers geselecteerd",
|
||||
"permissionReason": "Reden",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"noLibraries": "Geen bibliotheken geselecteerd",
|
||||
"librariesRequired": "",
|
||||
"requiredHosts": "Benodigde hosts",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Sleutel",
|
||||
"configValue": "Waarde"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
|
||||
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
|
||||
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
|
||||
"noTopSongsFound": "Geen beste nummers gevonden"
|
||||
"noTopSongsFound": "Geen beste nummers gevonden",
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotheek",
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Głębokość próbkowania",
|
||||
"sampleRate": "Częstotliwość próbkowania",
|
||||
"missing": "Brak",
|
||||
"libraryName": "Biblioteka"
|
||||
"libraryName": "Biblioteka",
|
||||
"composer": "Kompozytor"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Odtwarzaj Później",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Pobierz",
|
||||
"playNext": "Odtwarzaj Następny",
|
||||
"info": "Zdobądź Informacje",
|
||||
"showInPlaylist": "Pokaż w Liście Odtwarzania"
|
||||
"showInPlaylist": "Pokaż w Liście Odtwarzania",
|
||||
"instantMix": "Natychmiastowy Miks"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Skanowanie w trakcie...",
|
||||
"noLibrariesAssigned": "Brak bibliotek przypisanych do tego użytkownika"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "\nWtyczka |||| Wtyczki",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Nazwa",
|
||||
"description": "Opis",
|
||||
"version": "Wersja",
|
||||
"author": "Autor",
|
||||
"website": "Witryna",
|
||||
"permissions": "Uprawnienia",
|
||||
"enabled": "Aktywny",
|
||||
"status": "Status",
|
||||
"path": "Ścieżka",
|
||||
"lastError": "Błąd",
|
||||
"hasError": "Błąd",
|
||||
"updatedAt": "Zaktualizowana",
|
||||
"createdAt": "Zainstalowana",
|
||||
"configKey": "Klucz",
|
||||
"configValue": "Wartość",
|
||||
"allUsers": "Zezwalaj wszystkim użytkownikom",
|
||||
"selectedUsers": "Wybrani użytkownicy",
|
||||
"allLibraries": "Zezwalaj dla wszystkich bibliotek",
|
||||
"selectedLibraries": "Wybrane biblioteki"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Informacje O Wtyczce",
|
||||
"configuration": "Konfiguracja",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Uprawnienia Użytkowników",
|
||||
"libraryPermission": "Uprawnienia Biblioteki"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Włączona",
|
||||
"disabled": "Wyłączona"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Włącz",
|
||||
"disable": "Wyłącz",
|
||||
"disabledDueToError": "Napraw błąd przed włączeniem",
|
||||
"disabledUsersRequired": "Wybierz użytkowników przed włączeniem",
|
||||
"disabledLibrariesRequired": "Wybierz biblioteki przed włączaniem",
|
||||
"addConfig": "Dodaj Konfigurację",
|
||||
"rescan": "Przeskanuj Ponownie"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Wtyczka włączona",
|
||||
"disabled": "Wtyczka wyłączona",
|
||||
"updated": "Wtyczka zaktualizowana",
|
||||
"error": "Błąd aktualizacji wtyczki"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfiguracja musić być w poprawnym formacie JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Użyj par klucz-wartość, aby skonfigurować wtyczkę. Pozostaw puste, jeśli wtyczka nie wymaga konfiguracji.",
|
||||
"clickPermissions": "Kliknij uprawnienie, aby uzyskać szczegółowe informacje",
|
||||
"noConfig": "Nie wybrano konfiguracji",
|
||||
"allUsersHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich użytkowników, także tych utworzonych w przyszłości.",
|
||||
"noUsers": "Nie wybrano użytkowników",
|
||||
"permissionReason": "Powód",
|
||||
"usersRequired": "Ta wtyczka wymaga dostępu do informacji o użytkowniku. Wybierz użytkowników, do których wtyczka ma mieć dostęp, lub włącz opcję „Zezwól wszystkim użytkownikom”.",
|
||||
"allLibrariesHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich bibliotek, także tych utworzonych w przyszłości.",
|
||||
"noLibraries": "Nie wybrano biblioteki",
|
||||
"librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.",
|
||||
"requiredHosts": "Wymagane hosty",
|
||||
"configValidationError": "Weryfikacja konfiguracji nie powiodła się:",
|
||||
"schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "klucz",
|
||||
"configValue": "wartość"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Usuń wszystkie brakujące pliki",
|
||||
"remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.",
|
||||
"noSimilarSongsFound": "Brak podobnych utworów",
|
||||
"noTopSongsFound": "Brak najlepszych utworów"
|
||||
"noTopSongsFound": "Brak najlepszych utworów",
|
||||
"startingInstantMix": "Ładowanie Natychmiastowego Miksu..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteka",
|
||||
|
||||
@ -12,7 +12,6 @@
|
||||
"artist": "Artista",
|
||||
"album": "Álbum",
|
||||
"path": "Arquivo",
|
||||
"libraryName": "Biblioteca",
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
@ -36,7 +35,9 @@
|
||||
"rawTags": "Tags originais",
|
||||
"bitDepth": "Profundidade de bits",
|
||||
"sampleRate": "Taxa de amostragem",
|
||||
"missing": "Ausente"
|
||||
"missing": "Ausente",
|
||||
"libraryName": "Biblioteca",
|
||||
"composer": "Compositor"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
@ -59,7 +60,6 @@
|
||||
"songCount": "Músicas",
|
||||
"playCount": "Execuções",
|
||||
"name": "Nome",
|
||||
"libraryName": "Biblioteca",
|
||||
"genre": "Gênero",
|
||||
"compilation": "Coletânea",
|
||||
"year": "Ano",
|
||||
@ -79,7 +79,8 @@
|
||||
"media": "Mídia",
|
||||
"mood": "Mood",
|
||||
"date": "Data de Lançamento",
|
||||
"missing": "Ausente"
|
||||
"missing": "Ausente",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Tocar",
|
||||
@ -131,9 +132,9 @@
|
||||
"maincredit": "Artista do Álbum ou Artista |||| Artistas do Álbum ou Artistas"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Mais tocadas",
|
||||
"shuffle": "Aleatório",
|
||||
"radio": "Rádio"
|
||||
"radio": "Rádio",
|
||||
"topSongs": "Mais tocadas"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@ -162,14 +163,14 @@
|
||||
"updated": "Usuário atualizado com sucesso",
|
||||
"deleted": "Usuário deletado com sucesso"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Entre seu token do ListenBrainz",
|
||||
"clickHereForToken": "Clique aqui para obter seu token",
|
||||
"selectAllLibraries": "Selecionar todas as bibliotecas",
|
||||
"adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas"
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@ -254,17 +255,15 @@
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"createdAt": "Data de Criação",
|
||||
"downloadable": "Permitir Baixar?"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Arquivo ausente |||| Arquivos ausentes",
|
||||
"fields": {
|
||||
"path": "Caminho",
|
||||
"size": "Tamanho",
|
||||
"libraryName": "Biblioteca",
|
||||
"updatedAt": "Desaparecido em"
|
||||
"updatedAt": "Desaparecido em",
|
||||
"libraryName": "Biblioteca"
|
||||
},
|
||||
"actions": {
|
||||
"remove": "Remover",
|
||||
@ -303,20 +302,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Scanear Biblioteca",
|
||||
"quickScan": "Scan Rápido",
|
||||
"fullScan": "Scan Completo",
|
||||
"manageUsers": "Gerenciar Acesso do Usuário",
|
||||
"viewDetails": "Ver Detalhes"
|
||||
"viewDetails": "Ver Detalhes",
|
||||
"quickScan": "Scan Rápido",
|
||||
"fullScan": "Scan Completo"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Biblioteca criada com sucesso",
|
||||
"updated": "Biblioteca atualizada com sucesso",
|
||||
"deleted": "Biblioteca excluída com sucesso",
|
||||
"scanStarted": "Scan da biblioteca iniciada",
|
||||
"scanCompleted": "Scan da biblioteca concluída",
|
||||
"quickScanStarted": "Scan rápido iniciado",
|
||||
"fullScanStarted": "Scan completo iniciado",
|
||||
"scanError": "Erro ao iniciar o scan. Verifique os logs",
|
||||
"scanCompleted": "Scan da biblioteca concluída"
|
||||
"scanError": "Erro ao iniciar o scan. Verifique os logs"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Nome da biblioteca é obrigatório",
|
||||
@ -388,8 +387,6 @@
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.",
|
||||
"configValidationError": "Falha na validação da configuração:",
|
||||
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.",
|
||||
"clickPermissions": "Clique em uma permissão para ver detalhes",
|
||||
"noConfig": "Nenhuma configuração definida",
|
||||
"allUsersHelp": "Quando habilitado, o plugin terá acesso a todos os usuários, incluindo os criados no futuro.",
|
||||
@ -399,7 +396,9 @@
|
||||
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
|
||||
"noLibraries": "Nenhuma biblioteca selecionada",
|
||||
"librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.",
|
||||
"requiredHosts": "Hosts necessários"
|
||||
"requiredHosts": "Hosts necessários",
|
||||
"configValidationError": "Falha na validação da configuração:",
|
||||
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "chave",
|
||||
@ -557,9 +556,6 @@
|
||||
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
|
||||
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
|
||||
"songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist",
|
||||
"noSimilarSongsFound": "Nenhuma música semelhante encontrada",
|
||||
"startingInstantMix": "Carregando Mix Instantâneo...",
|
||||
"noTopSongsFound": "Nenhuma música mais tocada encontrada",
|
||||
"noPlaylistsAvailable": "Nenhuma playlist",
|
||||
"delete_user_title": "Excluir usuário '%{name}'",
|
||||
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?",
|
||||
@ -589,16 +585,13 @@
|
||||
"remove_missing_title": "Remover arquivos ausentes",
|
||||
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
|
||||
"remove_all_missing_title": "Remover todos os arquivos ausentes",
|
||||
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
|
||||
"remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
|
||||
"noSimilarSongsFound": "Nenhuma música semelhante encontrada",
|
||||
"noTopSongsFound": "Nenhuma música mais tocada encontrada",
|
||||
"startingInstantMix": "Carregando Mix Instantâneo..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Biblioteca",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas as Bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
|
||||
"selectLibraries": "Selecionar Bibliotecas",
|
||||
"none": "Nenhuma"
|
||||
},
|
||||
"settings": "Configurações",
|
||||
"version": "Versão",
|
||||
"theme": "Tema",
|
||||
@ -624,7 +617,13 @@
|
||||
"albumList": "Álbuns",
|
||||
"about": "Info",
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Compartilhadas"
|
||||
"sharedPlaylists": "Compartilhadas",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas as Bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
|
||||
"selectLibraries": "Selecionar Bibliotecas",
|
||||
"none": "Nenhuma"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de Execução",
|
||||
@ -683,17 +682,12 @@
|
||||
"totalScanned": "Total de pastas scaneadas",
|
||||
"quickScan": "Rápido",
|
||||
"fullScan": "Completo",
|
||||
"selectiveScan": "Seletivo",
|
||||
"serverUptime": "Uptime do servidor",
|
||||
"serverDown": "DESCONECTADO",
|
||||
"scanType": "Último Scan",
|
||||
"status": "Erro",
|
||||
"elapsedTime": "Duração"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Tocando agora",
|
||||
"empty": "Nada tocando",
|
||||
"minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás"
|
||||
"elapsedTime": "Duração",
|
||||
"selectiveScan": "Seletivo"
|
||||
},
|
||||
"help": {
|
||||
"title": "Teclas de atalho",
|
||||
@ -708,5 +702,10 @@
|
||||
"toggle_love": "Marcar/desmarcar favorita",
|
||||
"current_song": "Vai para música atual"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "Tocando agora",
|
||||
"empty": "Nada tocando",
|
||||
"minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Битовая глубина (Bit)",
|
||||
"sampleRate": "Частота дискретизации (Hz)",
|
||||
"missing": "Поле отсутствует",
|
||||
"libraryName": "Библиотека"
|
||||
"libraryName": "Библиотека",
|
||||
"composer": "Композитор"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "В очередь",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Скачать",
|
||||
"playNext": "Следующий",
|
||||
"info": "Информация",
|
||||
"showInPlaylist": "Показать в плейлисте"
|
||||
"showInPlaylist": "Показать в плейлисте",
|
||||
"instantMix": "Быстрый микс"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -93,7 +95,7 @@
|
||||
"lists": {
|
||||
"all": "Все",
|
||||
"random": "Случайные",
|
||||
"recentlyAdded": "Свежие",
|
||||
"recentlyAdded": "Новые",
|
||||
"recentlyPlayed": "Проигранные",
|
||||
"mostPlayed": "Популярные",
|
||||
"starred": "Избранные",
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "Сканирование продолжается...",
|
||||
"noLibrariesAssigned": "Нет библиотек, назначенных этому пользователю"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Плагин |||| Плагины",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Имя",
|
||||
"description": "Описание",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"website": "Вебсайт",
|
||||
"permissions": "Разрешения",
|
||||
"enabled": "Включено",
|
||||
"status": "Статус",
|
||||
"path": "Путь",
|
||||
"lastError": "Ошибка",
|
||||
"hasError": "Ошибка",
|
||||
"updatedAt": "Обновлено",
|
||||
"createdAt": "Установленный",
|
||||
"configKey": "Ключ",
|
||||
"configValue": "Значение",
|
||||
"allUsers": "Разрешить всем пользователям",
|
||||
"selectedUsers": "Выбранные пользователи",
|
||||
"allLibraries": "Разрешить доступ ко всем библиотекам",
|
||||
"selectedLibraries": "Избранные библиотеки"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Статус",
|
||||
"info": "Информация о плагине",
|
||||
"configuration": "Конфигурация",
|
||||
"manifest": "Манифест",
|
||||
"usersPermission": "Разрешение пользователей",
|
||||
"libraryPermission": "Разрешение на использование библиотеки"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключить"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Включить",
|
||||
"disable": "Отключить",
|
||||
"disabledDueToError": "Исправьте ошибку перед включением",
|
||||
"disabledUsersRequired": "Выберите пользователей перед включением",
|
||||
"disabledLibrariesRequired": "Выберите библиотеки перед включением",
|
||||
"addConfig": "Добавить конфигурацию",
|
||||
"rescan": "Повторное сканирование"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Плагин включен",
|
||||
"disabled": "Плагин отключен",
|
||||
"updated": "Плагин обновлен",
|
||||
"error": "Ошибка обновления плагина"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Конфигурация должна быть в формате JSON, допустимом для всех пользователей"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Настройте плагин, используя пары ключ-значение. Оставьте поле пустым, если плагин не требует настройки.",
|
||||
"clickPermissions": "Нажмите на разрешение для получения подробной информации",
|
||||
"noConfig": "Конфигурация не задана",
|
||||
"allUsersHelp": "При включении плагин получит доступ ко всем пользователям, включая тех, кто будет создан в будущем.",
|
||||
"noUsers": "Не выбрано ни одного пользователя",
|
||||
"permissionReason": "Причина",
|
||||
"usersRequired": "Этому плагину требуется доступ к пользовательской информации. Выберите, к каким пользователям плагин может получить доступ, или включите \"Разрешить всем пользователям\".",
|
||||
"allLibrariesHelp": "После включения плагин будет иметь доступ ко всем библиотекам, включая те, которые будут созданы в будущем.",
|
||||
"noLibraries": "Библиотеки не выбраны",
|
||||
"librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".",
|
||||
"requiredHosts": "Необходимые хосты",
|
||||
"configValidationError": "Проверка конфигурации завершилась неудачей:",
|
||||
"schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "ключ",
|
||||
"configValue": "значение"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "Удалите все отсутствующие файлы",
|
||||
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.",
|
||||
"noSimilarSongsFound": "Похожих треков не найдено",
|
||||
"noTopSongsFound": "Лучших треков не найдено"
|
||||
"noTopSongsFound": "Лучших треков не найдено",
|
||||
"startingInstantMix": "Загрузка быстрого микса"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Библиотека",
|
||||
@ -538,7 +615,7 @@
|
||||
}
|
||||
},
|
||||
"albumList": "Альбомы",
|
||||
"about": "О нас",
|
||||
"about": "О программе",
|
||||
"playlists": "Плейлисты",
|
||||
"sharedPlaylists": "Поделиться плейлистом",
|
||||
"librarySelector": {
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Bitna globina",
|
||||
"sampleRate": "Frekvenca vzorčenja",
|
||||
"missing": "Manjka",
|
||||
"libraryName": "Knjižnica"
|
||||
"libraryName": "Knjižnica",
|
||||
"composer": "Skladatelj"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Predvajaj kasneje",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "Naloži",
|
||||
"playNext": "Naslednji",
|
||||
"info": "Več informacij",
|
||||
"showInPlaylist": "Prikaži na seznamu predvajanja"
|
||||
"showInPlaylist": "Prikaži na seznamu predvajanja",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -301,14 +303,19 @@
|
||||
"actions": {
|
||||
"scan": "Skeniraj knjižnico",
|
||||
"manageUsers": "Upravljanje dostopa uporabnikov",
|
||||
"viewDetails": "Ogled podrobnosti"
|
||||
"viewDetails": "Ogled podrobnosti",
|
||||
"quickScan": "Hitro skeniranje",
|
||||
"fullScan": "Popolno skeniranje"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Knjižnica je uspešno ustvarjena",
|
||||
"updated": "Knjižnica je bila uspešno posodobljena",
|
||||
"deleted": "Knjižnica je uspešno izbrisana",
|
||||
"scanStarted": "Skeniranje knjižnice se je začelo",
|
||||
"scanCompleted": "Skeniranje knjižnice končano"
|
||||
"scanCompleted": "Skeniranje knjižnice končano",
|
||||
"quickScanStarted": "Hitro skeniranje se je začelo",
|
||||
"fullScanStarted": "Popolno skeniranje se je začelo",
|
||||
"scanError": "Napaka pri začetku skeniranja. Preverite dnevnike"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Ime knjižnice je obvezno",
|
||||
@ -323,6 +330,80 @@
|
||||
"scanInProgress": "Skeniranje v teku...",
|
||||
"noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Vtičnik |||| Vtičniki",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Ime",
|
||||
"description": "Opis",
|
||||
"version": "Verzija",
|
||||
"author": "Avtor",
|
||||
"website": "Spletna stran",
|
||||
"permissions": "Dovoljenja",
|
||||
"enabled": "Vključeno",
|
||||
"status": "Status",
|
||||
"path": "Pot",
|
||||
"lastError": "Napaka",
|
||||
"hasError": "Napaka",
|
||||
"updatedAt": "Posodobljeno",
|
||||
"createdAt": "Inštalirano",
|
||||
"configKey": "Ključ",
|
||||
"configValue": "Vrednost",
|
||||
"allUsers": "Dovoli vsem uporabnikom",
|
||||
"selectedUsers": "Izbrani uporabniki",
|
||||
"allLibraries": "Dovoli vse knjižnice",
|
||||
"selectedLibraries": "Izbrane knjižnice"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Informacije o vtičniku",
|
||||
"configuration": "Konfiguracija",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Uporabniška dovoljenja",
|
||||
"libraryPermission": "Knjižnična dovoljenja"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Vključeno",
|
||||
"disabled": "Izključeno"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Vključi",
|
||||
"disable": "Izključi",
|
||||
"disabledDueToError": "Popravi napako pred vključitvijo",
|
||||
"disabledUsersRequired": "Izberi uporabnike pred vključitvijo",
|
||||
"disabledLibrariesRequired": "Izberi knjižnice pred vključitvijo",
|
||||
"addConfig": "Dodaj konfiguracijo",
|
||||
"rescan": "Ponovi skeniranje"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Vtičnik vključen",
|
||||
"disabled": "Vtičnik izključen",
|
||||
"updated": "Vtičnik posodobljen",
|
||||
"error": "Napaka pri posodobitvi vtičnika"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfiguracija mora biti pravilen JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfiguriraj vtičnik z uporabo key-value parov. Pusti prazno, če vtičnik ne potrebuje konfiguracije.",
|
||||
"clickPermissions": "Klikni za dovoljenje o podrobnostih",
|
||||
"noConfig": "Konfiguracija ni nastavljena",
|
||||
"allUsersHelp": "Ko vključeno, bo vtičnik imel dostop do vseh uporabnikov, tudi prihodnjih.",
|
||||
"noUsers": "Uporabniki niso izbrani",
|
||||
"permissionReason": "Razlog",
|
||||
"usersRequired": "Vtičnik potrebuje dostop do uporabnikovih informacij. Izberi uporabnike ali vključi dostop vsem uporabnikom.",
|
||||
"allLibrariesHelp": "Ko vključeno, bo vtičnik imel dostop do vseh knjižnic, tudi prihodnjih.",
|
||||
"noLibraries": "Ni izbranih knjižnic",
|
||||
"librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.",
|
||||
"requiredHosts": "Zahtevani gostitelji",
|
||||
"configValidationError": "",
|
||||
"schemaRenderError": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "ključ",
|
||||
"configValue": "vrednost"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -506,7 +587,8 @@
|
||||
"remove_all_missing_title": "Odstrani vse manjkajoče datoteke",
|
||||
"remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
|
||||
"noSimilarSongsFound": "Ni najdenih podobnih pesmi",
|
||||
"noTopSongsFound": "Ni najdenih najboljših pesmi"
|
||||
"noTopSongsFound": "Ni najdenih najboljših pesmi",
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Knjižnica",
|
||||
@ -604,7 +686,8 @@
|
||||
"serverDown": "NEPOVEZAN",
|
||||
"scanType": "Tip",
|
||||
"status": "Napaka pri skeniranju",
|
||||
"elapsedTime": "Pretečeni čas"
|
||||
"elapsedTime": "Pretečeni čas",
|
||||
"selectiveScan": "Selektivno"
|
||||
},
|
||||
"help": {
|
||||
"title": "Hitre tipke",
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
"playCount": "Spelningar",
|
||||
"title": "Titel",
|
||||
"artist": "Artist",
|
||||
"composer": "Kompositör",
|
||||
"album": "Album",
|
||||
"path": "Sökväg",
|
||||
"genre": "Genre",
|
||||
@ -37,7 +36,8 @@
|
||||
"bitDepth": "Bitdjup",
|
||||
"sampleRate": "Samplingsfrekvens",
|
||||
"missing": "Saknade",
|
||||
"libraryName": "Bibliotek"
|
||||
"libraryName": "Bibliotek",
|
||||
"composer": "Kompositör"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Lägg till i kön",
|
||||
@ -47,7 +47,8 @@
|
||||
"download": "Ladda ner",
|
||||
"playNext": "Spela nästa",
|
||||
"info": "Mer information",
|
||||
"showInPlaylist": "Visa i spellista"
|
||||
"showInPlaylist": "Visa i spellista",
|
||||
"instantMix": "Direktmix"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -329,6 +330,80 @@
|
||||
"scanInProgress": "Scanning pågår...",
|
||||
"noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Tillägg |||| Tillägg",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Namn",
|
||||
"description": "Beskrivning",
|
||||
"version": "Version",
|
||||
"author": "Författare",
|
||||
"website": "Website",
|
||||
"permissions": "Behörigheter",
|
||||
"enabled": "Aktiverad",
|
||||
"status": "Status",
|
||||
"path": "Sökväg",
|
||||
"lastError": "Fel",
|
||||
"hasError": "Fel",
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Installerad",
|
||||
"configKey": "Nyckel",
|
||||
"configValue": "Värde",
|
||||
"allUsers": "Tillåt alla användare",
|
||||
"selectedUsers": "Valda användare",
|
||||
"allLibraries": "Tillåt alla bibliotek",
|
||||
"selectedLibraries": "Valda bibliotek"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Tilläggsinformation",
|
||||
"configuration": "Konfiguration",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Användarbehörigheter",
|
||||
"libraryPermission": "Biblioteksbehörigheter"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aktiverad",
|
||||
"disabled": "Inaktiverad"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aktivera",
|
||||
"disable": "Inaktivera",
|
||||
"disabledDueToError": "Åtgärda felet innan aktivering",
|
||||
"disabledUsersRequired": "Välj användare före aktivering",
|
||||
"disabledLibrariesRequired": "Välj bibliotek före aktivering",
|
||||
"addConfig": "Lägg till konfiguration",
|
||||
"rescan": "Scanna om"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Tillägg aktiverat",
|
||||
"disabled": "Tillägg inaktiverat",
|
||||
"updated": "Tillägg uppdaterat",
|
||||
"error": "Fel vid uppdatering av tillägg"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Konfigurationen måste vara giltig JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.",
|
||||
"clickPermissions": "Klicka på en behörighet för mer information",
|
||||
"noConfig": "Ingen konfiguration angiven",
|
||||
"allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.",
|
||||
"noUsers": "Inga användare valda",
|
||||
"permissionReason": "Orsak",
|
||||
"usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.",
|
||||
"allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.",
|
||||
"noLibraries": "Inga bibliotek valda",
|
||||
"librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
|
||||
"requiredHosts": "Krävda värdar",
|
||||
"configValidationError": "Validering av konfigurationen misslyckades:",
|
||||
"schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt."
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "nyckel",
|
||||
"configValue": "värde"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -512,7 +587,8 @@
|
||||
"remove_all_missing_title": "Ta bort alla saknade filer",
|
||||
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
|
||||
"noSimilarSongsFound": "Hittade inga liknande låtar",
|
||||
"noTopSongsFound": "Hittade inga topplåtar"
|
||||
"noTopSongsFound": "Hittade inga topplåtar",
|
||||
"startingInstantMix": "Laddar direktmix..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "Bibliotek",
|
||||
@ -545,7 +621,7 @@
|
||||
"librarySelector": {
|
||||
"allLibraries": "Alla bibliotek (%{count})",
|
||||
"multipleLibraries": "%{selected} av %{total} bibliotek",
|
||||
"selectLibraries": "Valda bibliotek",
|
||||
"selectLibraries": "Välj bibliotek",
|
||||
"none": "Inga"
|
||||
}
|
||||
},
|
||||
|
||||
@ -36,7 +36,8 @@
|
||||
"bitDepth": "Bit depth",
|
||||
"sampleRate": "แซมเปิ้ลเรต",
|
||||
"missing": "หายไป",
|
||||
"libraryName": "ห้องสมุด"
|
||||
"libraryName": "ห้องสมุด",
|
||||
"composer": "ผู้แต่ง"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "เพิ่มในคิว",
|
||||
@ -46,7 +47,8 @@
|
||||
"download": "ดาวน์โหลด",
|
||||
"playNext": "เล่นถัดไป",
|
||||
"info": "ดูรายละเอียด",
|
||||
"showInPlaylist": "แสดงในเพลย์ลิสต์"
|
||||
"showInPlaylist": "แสดงในเพลย์ลิสต์",
|
||||
"instantMix": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -328,6 +330,80 @@
|
||||
"scanInProgress": "กำลังสแกน...",
|
||||
"noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "ปลั๊กอิน |||| ปลั๊กอิน",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "ชื่อ",
|
||||
"description": "รายละเอียด",
|
||||
"version": "เวอร์ชั่น",
|
||||
"author": "ผู้สร้าง",
|
||||
"website": "เว็บไซต์",
|
||||
"permissions": "การอนุญาติ",
|
||||
"enabled": "เปิดใช้",
|
||||
"status": "สถานะ",
|
||||
"path": "เส้นทาง",
|
||||
"lastError": "ผิดพลาด",
|
||||
"hasError": "ผิดพลาด",
|
||||
"updatedAt": "อัพเดทแล้ว",
|
||||
"createdAt": "ติดตั้งแล้ว",
|
||||
"configKey": "คีย์",
|
||||
"configValue": "ค่า",
|
||||
"allUsers": "อนุญาติผู้ใช้ทั้งหมด",
|
||||
"selectedUsers": "ผู้ใช้ถูกเลือก",
|
||||
"allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
|
||||
"selectedLibraries": "ห้องสมุดเพลงถูกเลือก"
|
||||
},
|
||||
"sections": {
|
||||
"status": "สถานะ",
|
||||
"info": "ข้อมูลปลั๊กอิน",
|
||||
"configuration": "การตั้งค่า",
|
||||
"manifest": "แสดง",
|
||||
"usersPermission": "สิทธิของผู้ใช้",
|
||||
"libraryPermission": "สิทธิของห้องสมุดเพลง"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "เปิดใช้งานแล้ว",
|
||||
"disabled": "ปิดใช้งานแล้ว"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "เปิดใช้งาน",
|
||||
"disable": "ปิดใช้งาน",
|
||||
"disabledDueToError": "แก้ไขข้อผิดพลาดก่อนเปิดใช้งาน",
|
||||
"disabledUsersRequired": "เลือกผู้ใช้ที่จะเปิดใช้งาน",
|
||||
"disabledLibrariesRequired": "เลือกห้องสมุดเพลงที่จะเปิดใช้งาน",
|
||||
"addConfig": "เพิ่มการตั้งค่า",
|
||||
"rescan": "สแกนซ้ำ"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "เปิดใช้ปลั๊กอินแล้ว",
|
||||
"disabled": "ปิดใช้ปลั๊กอินแล้ว",
|
||||
"updated": "ปลั๊กอินอัพเดท",
|
||||
"error": "อัพเดทผิดพลาด"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "ต้องตั้งค่าตามไวยากรณ์ JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "ใส่ค่าให้เข้าคู่กับคีย์ของปลั๊กอิน ปล่อยว่างถ้าปลั๊กอินไม่ต้องการใช้",
|
||||
"clickPermissions": "กดดูรายละเอียดของการอนุญาติ",
|
||||
"noConfig": "ไม่ได้ตั้งค่า",
|
||||
"allUsersHelp": "เมื่อเปิดใช้ ปลั๊กอินจะใช้กับผู้ใช้ทุกคน รวมถึงผู้ใช้ใหม่ในอนาคต",
|
||||
"noUsers": "ไม่ได้เลือกผู้ใช้",
|
||||
"permissionReason": "เหตุผล",
|
||||
"usersRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลผู้ใช้ เลือกผู้ใช้ที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับผู้ใช้ทั้งหมด",
|
||||
"allLibrariesHelp": "เมื่อเปิดใช้งาน ปลั๊กอินจะเข้าถึงทุกห้องสมุดเพลง รวมถึงของผู้ใช้ใหม่ในอนาคต",
|
||||
"noLibraries": "ไม่มีห้องสมุดเพลงถูกเลือก",
|
||||
"librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด",
|
||||
"requiredHosts": "ต้องการ Host",
|
||||
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
|
||||
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "คีย์",
|
||||
"configValue": "ค่า"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -511,7 +587,8 @@
|
||||
"remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
|
||||
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
|
||||
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม"
|
||||
"noTopSongsFound": "ไม่พบเพลงยอดนิยม",
|
||||
"startingInstantMix": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "ห้องสมุดเพลง",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"playCount": "播放次數",
|
||||
"title": "標題",
|
||||
"artist": "藝人",
|
||||
"composer": "作曲者",
|
||||
"album": "專輯",
|
||||
"path": "檔案路徑",
|
||||
"libraryName": "媒體庫",
|
||||
@ -46,7 +47,8 @@
|
||||
"shuffleAll": "全部隨機播放",
|
||||
"download": "下載",
|
||||
"playNext": "下一首播放",
|
||||
"info": "取得資訊"
|
||||
"info": "取得資訊",
|
||||
"instantMix": "即時混音"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
@ -302,6 +304,8 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "掃描媒體庫",
|
||||
"quickScan": "快速掃描",
|
||||
"fullScan": "完整掃描",
|
||||
"manageUsers": "管理使用者權限",
|
||||
"viewDetails": "查看詳細資料"
|
||||
},
|
||||
@ -310,6 +314,9 @@
|
||||
"updated": "成功更新媒體庫",
|
||||
"deleted": "成功刪除媒體庫",
|
||||
"scanStarted": "開始掃描媒體庫",
|
||||
"quickScanStarted": "快速掃描已開始",
|
||||
"fullScanStarted": "完整掃描已開始",
|
||||
"scanError": "掃描啟動失敗,請檢查日誌",
|
||||
"scanCompleted": "媒體庫掃描完成"
|
||||
},
|
||||
"validation": {
|
||||
@ -325,6 +332,80 @@
|
||||
"scanInProgress": "正在掃描...",
|
||||
"noLibrariesAssigned": "沒有為該使用者指派任何媒體庫"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "插件 |||| 插件",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "名稱",
|
||||
"description": "描述",
|
||||
"version": "版本",
|
||||
"author": "作者",
|
||||
"website": "網站",
|
||||
"permissions": "權限",
|
||||
"enabled": "已啟用",
|
||||
"status": "狀態",
|
||||
"path": "路徑",
|
||||
"lastError": "錯誤",
|
||||
"hasError": "錯誤",
|
||||
"updatedAt": "更新於",
|
||||
"createdAt": "安裝於",
|
||||
"configKey": "鍵",
|
||||
"configValue": "值",
|
||||
"allUsers": "允許所有使用者",
|
||||
"selectedUsers": "選定的使用者",
|
||||
"allLibraries": "允許所有媒體庫",
|
||||
"selectedLibraries": "選定的媒體庫"
|
||||
},
|
||||
"sections": {
|
||||
"status": "狀態",
|
||||
"info": "插件資訊",
|
||||
"configuration": "設定",
|
||||
"manifest": "資訊清單",
|
||||
"usersPermission": "使用者權限",
|
||||
"libraryPermission": "媒體庫權限"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "已啟用",
|
||||
"disabled": "已停用"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"disabledDueToError": "修復錯誤後才能啟用",
|
||||
"disabledUsersRequired": "啟用前請先選擇使用者",
|
||||
"disabledLibrariesRequired": "啟用前請先選擇媒體庫",
|
||||
"addConfig": "新增設定",
|
||||
"rescan": "重新掃描"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "插件已啟用",
|
||||
"disabled": "插件已停用",
|
||||
"updated": "插件已更新",
|
||||
"error": "更新插件時發生錯誤"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "設定必須是有效的 JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "使用鍵值對設定插件。若插件無需設定則留空。",
|
||||
"configValidationError": "設定驗證失敗:",
|
||||
"schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。",
|
||||
"clickPermissions": "點擊權限以查看詳細資訊",
|
||||
"noConfig": "無設定",
|
||||
"allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。",
|
||||
"noUsers": "未選擇使用者",
|
||||
"permissionReason": "原因",
|
||||
"usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。",
|
||||
"allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。",
|
||||
"noLibraries": "未選擇媒體庫",
|
||||
"librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。",
|
||||
"requiredHosts": "必要的 Hosts"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "鍵",
|
||||
"configValue": "值"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@ -474,10 +555,11 @@
|
||||
},
|
||||
"message": {
|
||||
"note": "注意",
|
||||
"transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
|
||||
"transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。",
|
||||
"transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
|
||||
"songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
|
||||
"noSimilarSongsFound": "找不到相似歌曲",
|
||||
"startingInstantMix": "正在載入即時混音...",
|
||||
"noTopSongsFound": "找不到熱門歌曲",
|
||||
"noPlaylistsAvailable": "沒有可用的播放清單",
|
||||
"delete_user_title": "刪除使用者「%{name}」",
|
||||
@ -490,12 +572,12 @@
|
||||
"notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
|
||||
"lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
|
||||
"lastfmLinkFailure": "無法連接 Last.fm",
|
||||
"lastfmUnlinkSuccess": "已取消 Last.fm 的連接並停用音樂記錄",
|
||||
"lastfmUnlinkFailure": "無法取消 Last.fm 的連接",
|
||||
"listenBrainzLinkSuccess": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄",
|
||||
"lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄",
|
||||
"lastfmUnlinkFailure": "無法取消與 Last.fm 的連接",
|
||||
"listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄",
|
||||
"listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
|
||||
"listenBrainzUnlinkSuccess": "已取消 ListenBrainz 的連接並停用音樂記錄",
|
||||
"listenBrainzUnlinkFailure": "無法取消 ListenBrainz 的連接",
|
||||
"listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄",
|
||||
"listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接",
|
||||
"openIn": {
|
||||
"lastfm": "在 Last.fm 中開啟",
|
||||
"musicbrainz": "在 MusicBrainz 中開啟"
|
||||
@ -556,7 +638,7 @@
|
||||
"previousTrackText": "上一首",
|
||||
"reloadText": "重新載入",
|
||||
"volumeText": "音量",
|
||||
"toggleLyricText": "切換歌詞",
|
||||
"toggleLyricText": "歌詞顯示",
|
||||
"toggleMiniModeText": "最小化",
|
||||
"destroyText": "關閉",
|
||||
"downloadText": "下載",
|
||||
@ -602,6 +684,7 @@
|
||||
"totalScanned": "已掃描的資料夾總數",
|
||||
"quickScan": "快速掃描",
|
||||
"fullScan": "完全掃描",
|
||||
"selectiveScan": "選擇性掃描",
|
||||
"serverUptime": "伺服器運作時間",
|
||||
"serverDown": "伺服器已離線",
|
||||
"scanType": "掃描類型",
|
||||
|
||||
@ -224,6 +224,10 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ
|
||||
for _, w := range scanWarnings {
|
||||
log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w))
|
||||
}
|
||||
// Store scan error in database so it can be displayed in the UI
|
||||
if scanError != nil {
|
||||
_ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error())
|
||||
}
|
||||
// If changes were detected, send a refresh event to all clients
|
||||
if s.changesDetected {
|
||||
log.Debug(ctx, "Library changes imported. Sending refresh event")
|
||||
|
||||
@ -40,7 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
|
||||
job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
|
||||
state.sendWarning(err.Error())
|
||||
state.sendError(err)
|
||||
continue
|
||||
}
|
||||
jobs = append(jobs, job)
|
||||
|
||||
@ -51,8 +51,14 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MusicFolder = "default:///music" // Use a distinct schema for the default library
|
||||
conf.Server.DevExternalScanner = false
|
||||
|
||||
// Register an empty fake storage for the default library
|
||||
emptyFS := storagetest.FakeFS{}
|
||||
emptyFS.SetFiles(fstest.MapFS{})
|
||||
storagetest.Register("default", &emptyFS)
|
||||
|
||||
db.Init(ctx)
|
||||
DeferCleanup(func() {
|
||||
Expect(tests.ClearDB()).To(Succeed())
|
||||
@ -770,7 +776,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
||||
// Second scan should recover and import all rock content
|
||||
warnings, err = s.ScanAll(ctx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
|
||||
Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery")
|
||||
|
||||
// Verify both libraries now have content (at least jazz should work)
|
||||
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
|
||||
10
tests/fixtures/deezer.artist.bio.empty.json
vendored
Normal file
10
tests/fixtures/deezer.artist.bio.empty.json
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"data": {
|
||||
"artist": {
|
||||
"bio": null
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"queryCost": 3
|
||||
}
|
||||
}
|
||||
12
tests/fixtures/deezer.artist.bio.en.json
vendored
Normal file
12
tests/fixtures/deezer.artist.bio.en.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"data": {
|
||||
"artist": {
|
||||
"bio": {
|
||||
"full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act. First major single <em>\"Da Funk\"</em> was accompanied by a Spike Jonze-directed video and more success followed with global dance floor anthem <em>\"Around the World,\" \"One More Time,\"</em> and <em>\"Harder, Faster, Better, Stronger\"</em> - which was sampled by Kanye West for his hit <em>\"Stronger.\"</em> Albums <em>Homework</em> (1997), <em>Discovery</em> (2001) and <em>Human After All</em> (2005) all made the UK Top 10 establishing a style of simple, Chicago house-inspired grooves exploding into a robotic, rave sound.</p>"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"queryCost": 3
|
||||
}
|
||||
}
|
||||
12
tests/fixtures/deezer.artist.bio.fr.json
vendored
Normal file
12
tests/fixtures/deezer.artist.bio.fr.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"data": {
|
||||
"artist": {
|
||||
"bio": {
|
||||
"full": "Guy-Manuel de Homem Christo et Thomas Bangalter se rencontrent en 1987 au lycée Carnot de Paris. Partageant une même passion pour la musique, les deux amis fondent en 1992 Darlin', un groupe de rock influencé par les Stooges et MC5, dont la production sera taxée par un critique de la presse anglaise de «daft punk» (« punk idiot »). <br />\n<br />\nDécouragés face à l'apathie du milieu rock, ils décident un peu plus tard de se lancer à corps perdus dans le courant Techno alors en pleine explosion. Arrive alors la découverte de la House, des clubs et des raves, dont une en particulier qui déterminera leur avenir : en 1993 est organisé à EuroDisney une rave où notre duo rencontre les dirigeants du label techno écossais Soma."
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"queryCost": 3
|
||||
}
|
||||
}
|
||||
9
tests/fixtures/deezer.artist.bio.json
vendored
9
tests/fixtures/deezer.artist.bio.json
vendored
@ -1,9 +0,0 @@
|
||||
{
|
||||
"data": {
|
||||
"artist": {
|
||||
"bio": {
|
||||
"full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/fixtures/lastfm.album.getinfo.empty.json
vendored
Normal file
1
tests/fixtures/lastfm.album.getinfo.empty.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
tests/fixtures/lastfm.album.getinfo.en.json
vendored
Normal file
1
tests/fixtures/lastfm.album.getinfo.en.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
tests/fixtures/lastfm.artist.getinfo.empty.json
vendored
Normal file
1
tests/fixtures/lastfm.artist.getinfo.empty.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
tests/fixtures/lastfm.artist.getinfo.en.json
vendored
Normal file
1
tests/fixtures/lastfm.artist.getinfo.en.json
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -76,6 +76,10 @@ func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, er
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
return m.GetAll(options...)
|
||||
}
|
||||
|
||||
func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
if len(qo) > 0 {
|
||||
m.Options = qo[0]
|
||||
|
||||
24
ui/package-lock.json
generated
24
ui/package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"connected-react-router": "^6.9.3",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"history": "^4.10.1",
|
||||
"inflection": "^3.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
@ -5502,10 +5503,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
|
||||
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "9.0.0",
|
||||
@ -8379,9 +8383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
@ -9677,6 +9681,12 @@
|
||||
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ra-ui-materialui/node_modules/dompurify": {
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
|
||||
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)"
|
||||
},
|
||||
"node_modules/ra-ui-materialui/node_modules/inflection": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"connected-react-router": "^6.9.3",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"history": "^4.10.1",
|
||||
"inflection": "^3.0.2",
|
||||
"jwt-decode": "^4.0.0",
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
import config from '../config'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -225,8 +226,7 @@ const AlbumDetails = (props) => {
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
let notes =
|
||||
albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes
|
||||
let notes = albumInfo?.notes || record.notes
|
||||
|
||||
if (notes) {
|
||||
notes += '..'
|
||||
@ -351,7 +351,9 @@ const AlbumDetails = (props) => {
|
||||
variant={'body1'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: notes }} />
|
||||
<span>
|
||||
<SafeHTML>{notes}</SafeHTML>
|
||||
</span>
|
||||
</Typography>
|
||||
</Collapse>
|
||||
)}
|
||||
@ -371,7 +373,9 @@ const AlbumDetails = (props) => {
|
||||
variant={'body1'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: notes }} />
|
||||
<span>
|
||||
<SafeHTML>{notes}</SafeHTML>
|
||||
</span>
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, createElement, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMediaQuery, withWidth } from '@material-ui/core'
|
||||
import {
|
||||
useShowController,
|
||||
@ -53,9 +53,7 @@ const ArtistDetails = (props) => {
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||
const [artistInfo, setArtistInfo] = useState()
|
||||
|
||||
const biography =
|
||||
artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '') ||
|
||||
record.biography
|
||||
const biography = artistInfo?.biography || record.biography
|
||||
|
||||
useEffect(() => {
|
||||
subsonic
|
||||
@ -72,15 +70,9 @@ const ArtistDetails = (props) => {
|
||||
})
|
||||
}, [record.id])
|
||||
|
||||
const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
|
||||
const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails
|
||||
return (
|
||||
<>
|
||||
{createElement(component, {
|
||||
artistInfo,
|
||||
record,
|
||||
biography,
|
||||
})}
|
||||
</>
|
||||
<Component artistInfo={artistInfo} record={record} biography={biography} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import Lightbox from 'react-image-lightbox'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import AlbumInfo from '../album/AlbumInfo'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -172,7 +173,9 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
variant={'body1'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
<span>
|
||||
<SafeHTML>{biography}</SafeHTML>
|
||||
</span>
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
|
||||
@ -7,6 +7,7 @@ import config from '../config'
|
||||
import { LoveButton, RatingField } from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -168,7 +169,9 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
<div className={classes.biography}>
|
||||
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
|
||||
<Typography variant={'body1'} onClick={() => setExpanded(!expanded)}>
|
||||
<span dangerouslySetInnerHTML={{ __html: biography }} />
|
||||
<span>
|
||||
<SafeHTML>{biography}</SafeHTML>
|
||||
</span>
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
@ -53,12 +53,7 @@ const Linkify = ({ text, ...rest }) => {
|
||||
|
||||
// Push remaining text
|
||||
if (text.length > lastIndex) {
|
||||
elements.push(
|
||||
<span
|
||||
key={'last-span-key'}
|
||||
dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }}
|
||||
/>,
|
||||
)
|
||||
elements.push(text.substring(lastIndex))
|
||||
}
|
||||
|
||||
return elements.length === 1 ? elements[0] : elements
|
||||
|
||||
@ -28,19 +28,7 @@ export const MultiLineTextField = memo(
|
||||
component="span"
|
||||
{...sanitizeFieldRestProps(rest)}
|
||||
>
|
||||
{lines.length === 0 && emptyText
|
||||
? emptyText
|
||||
: lines.map((line, idx) =>
|
||||
line === '' ? (
|
||||
<br key={md5(line + idx)} />
|
||||
) : (
|
||||
<div
|
||||
data-testid={`${source}.${idx}`}
|
||||
key={md5(line + idx)}
|
||||
dangerouslySetInnerHTML={{ __html: line }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lines.length === 0 && emptyText ? emptyText : lines}
|
||||
</Typography>
|
||||
)
|
||||
},
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { render, cleanup, screen } from '@testing-library/react'
|
||||
import { MultiLineTextField } from './MultiLineTextField'
|
||||
|
||||
describe('<MultiLineTextField />', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('should render each line in a separated div', () => {
|
||||
const record = { comment: 'line1\nline2' }
|
||||
render(<MultiLineTextField record={record} source={'comment'} />)
|
||||
expect(screen.queryByTestId('comment.0').textContent).toBe('line1')
|
||||
expect(screen.queryByTestId('comment.1').textContent).toBe('line2')
|
||||
})
|
||||
|
||||
it.each([null, undefined])(
|
||||
'should render the emptyText when value is %s',
|
||||
(body) => {
|
||||
render(
|
||||
<MultiLineTextField
|
||||
record={{ id: 123, body }}
|
||||
emptyText="NA"
|
||||
source="body"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('NA')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
27
ui/src/common/SafeHTML.jsx
Normal file
27
ui/src/common/SafeHTML.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
|
||||
export const SafeHTML = ({ children }) => {
|
||||
const purified = useMemo(() => {
|
||||
const purify = DOMPurify()
|
||||
|
||||
purify.addHook('afterSanitizeElements', async (node) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
// Set referrer-policy for elements with src
|
||||
switch (node.tagName.toLowerCase()) {
|
||||
case 'a':
|
||||
case 'area':
|
||||
case 'img':
|
||||
case 'video':
|
||||
case 'iframe':
|
||||
case 'script':
|
||||
node.setAttribute('referrer-policy', 'no-referrer')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] })
|
||||
}, [children])
|
||||
|
||||
return <Fragment dangerouslySetInnerHTML={{ __html: purified }} />
|
||||
}
|
||||
@ -96,7 +96,7 @@ export const SongContextMenu = ({
|
||||
const id = record.mediaFileId || record.id
|
||||
await playSimilar(dispatch, notify, id, {
|
||||
seedRecord: record,
|
||||
shuffle: true,
|
||||
shuffle: false,
|
||||
})
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
Typography,
|
||||
} from '@material-ui/core'
|
||||
import { FiActivity } from 'react-icons/fi'
|
||||
import { BiError } from 'react-icons/bi'
|
||||
import { BiError, BiMessageError } from 'react-icons/bi'
|
||||
import { VscSync } from 'react-icons/vsc'
|
||||
import { GiMagnifyingGlass } from 'react-icons/gi'
|
||||
import subsonic from '../subsonic'
|
||||
@ -28,7 +28,12 @@ import config from '../config'
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
wrapper: {
|
||||
position: 'relative',
|
||||
color: (props) => (props.up ? null : 'orange'),
|
||||
color: (props) =>
|
||||
props.serverDown
|
||||
? theme.palette.error.main
|
||||
: props.hasWarning
|
||||
? theme.palette.warning.main
|
||||
: null,
|
||||
},
|
||||
progress: {
|
||||
color: theme.palette.primary.light,
|
||||
@ -75,12 +80,10 @@ const ActivityPanel = () => {
|
||||
scanStatus.scanning,
|
||||
scanStatus.elapsedTime,
|
||||
)
|
||||
const [acknowledgedError, setAcknowledgedError] = useState(null)
|
||||
const isErrorVisible =
|
||||
scanStatus.error && scanStatus.error !== acknowledgedError
|
||||
const classes = useStyles({
|
||||
up: up && (!scanStatus.error || !isErrorVisible),
|
||||
})
|
||||
// Determine icon state: error (server down), warning (scan error), or normal
|
||||
const serverDown = !up
|
||||
const hasWarning = Boolean(scanStatus.error)
|
||||
const classes = useStyles({ serverDown, hasWarning })
|
||||
const translate = useTranslate()
|
||||
const notify = useNotify()
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
@ -88,13 +91,12 @@ const ActivityPanel = () => {
|
||||
useInitialScanStatus()
|
||||
|
||||
const handleMenuOpen = (event) => {
|
||||
if (scanStatus.error) {
|
||||
setAcknowledgedError(scanStatus.error)
|
||||
}
|
||||
setAnchorEl(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleMenuClose = () => setAnchorEl(null)
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
const triggerScan = (full) => () => subsonic.startScan({ fullScan: full })
|
||||
|
||||
useEffect(() => {
|
||||
@ -125,8 +127,10 @@ const ActivityPanel = () => {
|
||||
<div className={classes.wrapper}>
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<IconButton className={classes.button} onClick={handleMenuOpen}>
|
||||
{!up || isErrorVisible ? (
|
||||
{serverDown ? (
|
||||
<BiError data-testid="activity-error-icon" size={'20'} />
|
||||
) : hasWarning ? (
|
||||
<BiMessageError data-testid="activity-warning-icon" size={'20'} />
|
||||
) : (
|
||||
<FiActivity data-testid="activity-ok-icon" size={'20'} />
|
||||
)}
|
||||
@ -155,7 +159,11 @@ const ActivityPanel = () => {
|
||||
<Box component="span" flex={2}>
|
||||
{translate('activity.serverUptime')}:
|
||||
</Box>
|
||||
<Box component="span" flex={1}>
|
||||
<Box
|
||||
component="span"
|
||||
flex={1}
|
||||
className={!up ? classes.error : null}
|
||||
>
|
||||
{up ? <Uptime /> : translate('activity.serverDown')}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -43,19 +43,47 @@ describe('<ActivityPanel />', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the error icon after opening the panel', () => {
|
||||
it('shows warning icon when server reports a scan error', () => {
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<ActivityPanel />
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
// Warning icon should be visible when there's a scan error
|
||||
expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument()
|
||||
|
||||
// Open the panel - warning icon should still be visible
|
||||
const button = screen.getByRole('button')
|
||||
expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Scan failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error icon when server is down', () => {
|
||||
const downStore = createStore(
|
||||
combineReducers({ activity: activityReducer }),
|
||||
{
|
||||
activity: {
|
||||
scanStatus: {
|
||||
scanning: false,
|
||||
folderCount: 0,
|
||||
count: 0,
|
||||
error: '',
|
||||
elapsedTime: 0,
|
||||
},
|
||||
serverStart: { version: config.version, startTime: null }, // null startTime = server down
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
render(
|
||||
<Provider store={downStore}>
|
||||
<ActivityPanel />
|
||||
</Provider>,
|
||||
)
|
||||
|
||||
// Error icon should be visible when server is down
|
||||
expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -136,6 +136,8 @@ const FormLogin = ({ loading, handleSubmit, validate }) => {
|
||||
{config.welcomeMessage && (
|
||||
<div
|
||||
className={classes.welcome}
|
||||
// Use dangerouslySetInnerHTML to allow admins to configure
|
||||
// whatever content they want
|
||||
dangerouslySetInnerHTML={{ __html: config.welcomeMessage }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -42,7 +42,7 @@ SchemaErrorBoundary.propTypes = {
|
||||
// params.missingProperty. We transform them to point to the field directly
|
||||
// (e.g., "/users/1/username") so JSONForms displays them under the correct input.
|
||||
const ajv = new Ajv({
|
||||
useDefaults: false,
|
||||
useDefaults: true,
|
||||
allErrors: true,
|
||||
verbose: true,
|
||||
jsonPointers: true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user