mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-05 12:31:10 -05:00
Compare commits
51 Commits
global-nsp
...
v0.60.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8f2a559c | ||
|
|
a1036e75a9 | ||
|
|
2829cec0ce | ||
|
|
ddff5db14a | ||
|
|
d7ec7355c9 | ||
|
|
c3a4585c83 | ||
|
|
2068e7d413 | ||
|
|
15526b25e5 | ||
|
|
948f6507c1 | ||
|
|
9bce7677f5 | ||
|
|
7b709899a1 | ||
|
|
ebbc31f1ab | ||
|
|
84ab652ca7 | ||
|
|
f13ca58c98 | ||
|
|
36252823ce | ||
|
|
7d5e13672d | ||
|
|
4c2bd7509c | ||
|
|
7b523d6b61 | ||
|
|
c9e58e3666 | ||
|
|
77367548f6 | ||
|
|
71f549afbf | ||
|
|
1afcf7775b | ||
|
|
a55c4f0410 | ||
|
|
5db585e1b1 | ||
|
|
63517e904c | ||
|
|
51026de80b | ||
|
|
fda35dd8ce | ||
|
|
4d4740b83b | ||
|
|
772d1f359b | ||
|
|
b455546fdf | ||
|
|
c6c1c16923 | ||
|
|
75dd28678f | ||
|
|
1c4a7e8556 | ||
|
|
b1b488be77 | ||
|
|
6fce30c133 | ||
|
|
6c7f8314e2 | ||
|
|
37aa54fe06 | ||
|
|
fae58bb390 | ||
|
|
f1e75c40dc | ||
|
|
66474fc9f4 | ||
|
|
fd620413b8 | ||
|
|
4ec6e7c56e | ||
|
|
03120bac32 | ||
|
|
0473c50b49 | ||
|
|
2de2484bca | ||
|
|
64e165aaef | ||
|
|
8e96dd0784 | ||
|
|
9bd91d2c04 | ||
|
|
c5447a637a | ||
|
|
b9247ba34e | ||
|
|
510acde3db |
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' }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,4 +35,5 @@ AGENTS.md
|
||||
*.test
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
openspec/
|
||||
go.work*
|
||||
@@ -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() {
|
||||
@@ -45,6 +45,28 @@ var _ = Describe("client", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TopTracks", func() {
|
||||
It("returns top tracks with artist and album info from a successful request", func() {
|
||||
f, err := os.Open("tests/fixtures/deezer.artist.top.json")
|
||||
Expect(err).To(BeNil())
|
||||
httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
|
||||
|
||||
tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(tracks).To(HaveLen(5))
|
||||
|
||||
// Verify first track has all expected fields
|
||||
Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
|
||||
Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
|
||||
Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
|
||||
|
||||
// Verify second track
|
||||
Expect(tracks[1].Title).To(Equal("One More Time"))
|
||||
Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
|
||||
Expect(tracks[1].Album.Title).To(Equal("Discovery"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ArtistBio", func() {
|
||||
BeforeEach(func() {
|
||||
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
|
||||
@@ -56,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")
|
||||
@@ -120,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"))
|
||||
@@ -142,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"))
|
||||
})
|
||||
|
||||
@@ -152,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"))
|
||||
})
|
||||
@@ -165,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
|
||||
}
|
||||
|
||||
@@ -135,7 +139,9 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st
|
||||
|
||||
res := slice.Map(tracks, func(r Track) agents.Song {
|
||||
return agents.Song{
|
||||
Name: r.Title,
|
||||
Name: r.Title,
|
||||
Album: r.Album.Title,
|
||||
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
|
||||
}
|
||||
})
|
||||
return res, nil
|
||||
@@ -147,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())
|
||||
}
|
||||
274
adapters/gotaglib/end_to_end_test.go
Normal file
274
adapters/gotaglib/end_to_end_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package gotaglib
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/djherbis/times"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type testFileInfo struct {
|
||||
fs.FileInfo
|
||||
}
|
||||
|
||||
func (t testFileInfo) BirthTime() time.Time {
|
||||
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
|
||||
return ts.BirthTime()
|
||||
}
|
||||
return t.FileInfo.ModTime()
|
||||
}
|
||||
|
||||
var _ = Describe("Extractor", func() {
|
||||
toP := func(name, sortName, mbid string) model.Participant {
|
||||
return model.Participant{
|
||||
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
|
||||
}
|
||||
}
|
||||
|
||||
roles := []struct {
|
||||
model.Role
|
||||
model.ParticipantList
|
||||
}{
|
||||
{model.RoleComposer, model.ParticipantList{
|
||||
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
|
||||
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
|
||||
}},
|
||||
{model.RoleLyricist, model.ParticipantList{
|
||||
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
|
||||
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
|
||||
}},
|
||||
{model.RoleArranger, model.ParticipantList{
|
||||
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
|
||||
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
|
||||
}},
|
||||
{model.RoleConductor, model.ParticipantList{
|
||||
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
|
||||
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
|
||||
}},
|
||||
{model.RoleDirector, model.ParticipantList{
|
||||
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
|
||||
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
|
||||
}},
|
||||
{model.RoleEngineer, model.ParticipantList{
|
||||
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
|
||||
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
|
||||
}},
|
||||
{model.RoleProducer, model.ParticipantList{
|
||||
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
|
||||
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
|
||||
}},
|
||||
{model.RoleRemixer, model.ParticipantList{
|
||||
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
|
||||
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
|
||||
}},
|
||||
{model.RoleDJMixer, model.ParticipantList{
|
||||
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
|
||||
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
|
||||
}},
|
||||
{model.RoleMixer, model.ParticipantList{
|
||||
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
|
||||
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
|
||||
}},
|
||||
}
|
||||
|
||||
var e *extractor
|
||||
|
||||
parseTestFile := func(path string) *model.MediaFile {
|
||||
mds, err := e.Parse(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
info, ok := mds[path]
|
||||
Expect(ok).To(BeTrue())
|
||||
|
||||
fileInfo, err := os.Stat(path)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
mf := metadata.ToMediaFile(1, "folderID")
|
||||
return &mf
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
e = &extractor{fs: os.DirFS(".")}
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
|
||||
Expect(mf.RGTrackGain).To(Equal(trackGain))
|
||||
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
|
||||
Expect(mf.RGAlbumGain).To(Equal(albumGain))
|
||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||
},
|
||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("lyrics", func() {
|
||||
makeLyrics := func(code, secondLine string) model.Lyrics {
|
||||
return model.Lyrics{
|
||||
DisplayArtist: "",
|
||||
DisplayTitle: "",
|
||||
Lang: code,
|
||||
Line: []model.Line{
|
||||
{Start: gg.P(int64(0)), Value: "This is"},
|
||||
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||
},
|
||||
Offset: nil,
|
||||
Synced: true,
|
||||
}
|
||||
}
|
||||
|
||||
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
|
||||
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(2))
|
||||
|
||||
Expect(lyrics[0].Synced).To(BeTrue())
|
||||
Expect(lyrics[1].Synced).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should handle mp3 with uslt and sylt", func() {
|
||||
mf := parseTestFile("tests/fixtures/test.mp3")
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(lyrics).To(HaveLen(4))
|
||||
|
||||
engSylt := makeLyrics("eng", "English SYLT")
|
||||
engUslt := makeLyrics("eng", "English")
|
||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||
unsUslt := makeLyrics("xxx", "unspecified")
|
||||
|
||||
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||
})
|
||||
|
||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||
mf := parseTestFile("tests/fixtures/" + file)
|
||||
|
||||
lyrics, err := mf.StructuredLyrics()
|
||||
Expect(err).To(Not(HaveOccurred()))
|
||||
Expect(lyrics).To(HaveLen(2))
|
||||
|
||||
unspec := makeLyrics("xxx", "unspecified")
|
||||
eng := makeLyrics("xxx", "English")
|
||||
|
||||
if isId3 {
|
||||
eng.Lang = "eng"
|
||||
}
|
||||
|
||||
Expect(lyrics).To(Or(
|
||||
Equal(model.LyricList{unspec, eng}),
|
||||
Equal(model.LyricList{eng, unspec})))
|
||||
},
|
||||
Entry("flac", "test.flac", false),
|
||||
Entry("m4a", "test.m4a", false),
|
||||
Entry("ogg", "test.ogg", false),
|
||||
Entry("wma", "test.wma", false),
|
||||
Entry("wv", "test.wv", false),
|
||||
Entry("wav", "test.wav", true),
|
||||
Entry("aiff", "test.aiff", true),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Participants", func() {
|
||||
DescribeTable("test tags consistent across formats", func(format string) {
|
||||
mf := parseTestFile("tests/fixtures/test." + format)
|
||||
|
||||
for _, data := range roles {
|
||||
role := data.Role
|
||||
artists := data.ParticipantList
|
||||
|
||||
actual := mf.Participants[role]
|
||||
Expect(actual).To(HaveLen(len(artists)))
|
||||
|
||||
for i := range artists {
|
||||
actualArtist := actual[i]
|
||||
expectedArtist := artists[i]
|
||||
|
||||
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
|
||||
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
|
||||
}
|
||||
}
|
||||
|
||||
if format != "m4a" {
|
||||
performers := mf.Participants[model.RolePerformer]
|
||||
Expect(performers).To(HaveLen(8))
|
||||
|
||||
rules := map[string][]string{
|
||||
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
|
||||
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
|
||||
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
|
||||
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
|
||||
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
|
||||
}
|
||||
|
||||
for name, rule := range rules {
|
||||
mbid := rule[0]
|
||||
for i := 1; i < len(rule); i++ {
|
||||
found := false
|
||||
|
||||
for _, mapped := range performers {
|
||||
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Expect(found).To(BeTrue(), "Could not find matching artist")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Entry("FLAC format", "flac"),
|
||||
Entry("M4a format", "m4a"),
|
||||
Entry("OGG format", "ogg"),
|
||||
Entry("WV format", "wv"),
|
||||
|
||||
Entry("MP3 format", "mp3"),
|
||||
Entry("WAV format", "wav"),
|
||||
Entry("AIFF format", "aiff"),
|
||||
)
|
||||
|
||||
It("should parse wma", func() {
|
||||
mf := parseTestFile("tests/fixtures/test.wma")
|
||||
|
||||
for _, data := range roles {
|
||||
role := data.Role
|
||||
artists := data.ParticipantList
|
||||
actual := mf.Participants[role]
|
||||
|
||||
// WMA has no Arranger role
|
||||
if role == model.RoleArranger {
|
||||
Expect(actual).To(HaveLen(0))
|
||||
continue
|
||||
}
|
||||
|
||||
Expect(actual).To(HaveLen(len(artists)), role.String())
|
||||
|
||||
// For some bizarre reason, the order is inverted. We also don't get
|
||||
// sort names or MBIDs
|
||||
for i := range artists {
|
||||
idx := len(artists) - 1 - i
|
||||
|
||||
actualArtist := actual[i]
|
||||
expectedArtist := artists[idx]
|
||||
|
||||
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
263
adapters/gotaglib/gotaglib.go
Normal file
263
adapters/gotaglib/gotaglib.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// Package gotaglib provides an alternative metadata extractor using go-taglib,
|
||||
// a pure Go (WASM-based) implementation of TagLib.
|
||||
//
|
||||
// This extractor aims for parity with the CGO-based taglib extractor. It uses
|
||||
// TagLib's PropertyMap interface for standard tags. The File handle API provides
|
||||
// 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 "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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
type extractor struct {
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||
results := make(map[string]metadata.Info)
|
||||
for _, path := range files {
|
||||
props, err := e.extractMetadata(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results[path] = *props
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (e extractor) Version() string {
|
||||
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||
}
|
||||
|
||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
f, close, err := e.openFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer close()
|
||||
|
||||
// Get all tags and properties in one go
|
||||
allTags := f.AllTags()
|
||||
props := f.Properties()
|
||||
|
||||
// Map properties to AudioProperties
|
||||
ap := metadata.AudioProperties{
|
||||
Duration: props.Length.Round(time.Millisecond * 10),
|
||||
BitRate: int(props.Bitrate),
|
||||
Channels: int(props.Channels),
|
||||
SampleRate: int(props.SampleRate),
|
||||
BitDepth: int(props.BitsPerSample),
|
||||
}
|
||||
|
||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||
normalizedTags := make(map[string][]string, len(allTags.Tags))
|
||||
for key, values := range allTags.Tags {
|
||||
lowerKey := strings.ToLower(key)
|
||||
normalizedTags[lowerKey] = values
|
||||
}
|
||||
|
||||
// Process format-specific raw tags
|
||||
processRawTags(allTags, normalizedTags)
|
||||
|
||||
// Parse track/disc totals from "N/Total" format
|
||||
parseTuple(normalizedTags, "track")
|
||||
parseTuple(normalizedTags, "disc")
|
||||
|
||||
// Adjust some ID3 tags
|
||||
parseLyrics(normalizedTags)
|
||||
parseTIPL(normalizedTags)
|
||||
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
|
||||
|
||||
// Determine if file has embedded picture
|
||||
hasPicture := len(props.Images) > 0
|
||||
|
||||
return &metadata.Info{
|
||||
Tags: normalizedTags,
|
||||
AudioProperties: ap,
|
||||
HasPicture: hasPicture,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openFile opens the file at filePath using the extractor's filesystem.
|
||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
||||
func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
|
||||
// Open the file from the filesystem
|
||||
file, err := e.fs.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rs, isSeekable := file.(io.ReadSeeker)
|
||||
if !isSeekable {
|
||||
file.Close()
|
||||
return nil, nil, errors.New("file is not seekable")
|
||||
}
|
||||
f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
closeFunc := func() {
|
||||
f.Close()
|
||||
file.Close()
|
||||
}
|
||||
return f, closeFunc, nil
|
||||
}
|
||||
|
||||
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
|
||||
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
|
||||
func parseTuple(tags map[string][]string, prop string) {
|
||||
tagName := prop + "number"
|
||||
tagTotal := prop + "total"
|
||||
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
||||
parts := strings.Split(value[0], "/")
|
||||
tags[tagName] = []string{parts[0]}
|
||||
if len(parts) == 2 {
|
||||
tags[tagTotal] = []string{parts[1]}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseLyrics ensures lyrics tags have a language code.
|
||||
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
|
||||
func parseLyrics(tags map[string][]string) {
|
||||
lyrics := tags["lyrics"]
|
||||
if len(lyrics) > 0 {
|
||||
tags["lyrics:xxx"] = lyrics
|
||||
delete(tags, "lyrics")
|
||||
}
|
||||
}
|
||||
|
||||
// processRawTags processes format-specific raw tags based on the detected file format.
|
||||
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
|
||||
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
|
||||
switch allTags.Format {
|
||||
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
|
||||
parseID3v2Frames(allTags.Raw, normalizedTags)
|
||||
case taglib.FormatMP4:
|
||||
parseMP4Atoms(allTags.Raw, normalizedTags)
|
||||
case taglib.FormatASF:
|
||||
parseASFAttributes(allTags.Raw, normalizedTags)
|
||||
}
|
||||
}
|
||||
|
||||
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
|
||||
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
|
||||
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
|
||||
// Process frames that have language-specific data
|
||||
for key, values := range rawFrames {
|
||||
lowerKey := strings.ToLower(key)
|
||||
|
||||
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
|
||||
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
|
||||
parts := strings.SplitN(lowerKey, ":", 2)
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
lang := parts[1]
|
||||
lyricsKey := "lyrics:" + lang
|
||||
tags[lyricsKey] = append(tags[lyricsKey], values...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
|
||||
for key := range tags {
|
||||
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
|
||||
delete(tags, "lyrics")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const iTunesKeyPrefix = "----:com.apple.iTunes:"
|
||||
|
||||
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
|
||||
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
|
||||
// Process all atoms and add them to tags
|
||||
for key, values := range rawAtoms {
|
||||
// Strip iTunes prefix and convert to lowercase
|
||||
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
|
||||
normalizedKey = strings.ToLower(normalizedKey)
|
||||
|
||||
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
||||
if _, exists := tags[normalizedKey]; !exists {
|
||||
tags[normalizedKey] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
|
||||
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
|
||||
// Process all attributes and add them to tags
|
||||
for key, values := range rawAttrs {
|
||||
normalizedKey := strings.ToLower(key)
|
||||
|
||||
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
||||
if _, exists := tags[normalizedKey]; !exists {
|
||||
tags[normalizedKey] = values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These are the only roles we support, based on Picard's tag map:
|
||||
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
||||
var tiplMapping = map[string]string{
|
||||
"arranger": "arranger",
|
||||
"engineer": "engineer",
|
||||
"producer": "producer",
|
||||
"mix": "mixer",
|
||||
"DJ-mix": "djmixer",
|
||||
}
|
||||
|
||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||
//
|
||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||
//
|
||||
// and breaks it down into a map of roles and names, e.g.:
|
||||
//
|
||||
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
||||
func parseTIPL(tags map[string][]string) {
|
||||
tipl := tags["tipl"]
|
||||
if len(tipl) == 0 {
|
||||
return
|
||||
}
|
||||
addRole := func(currentRole string, currentValue []string) {
|
||||
if currentRole != "" && len(currentValue) > 0 {
|
||||
role := tiplMapping[currentRole]
|
||||
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
||||
}
|
||||
}
|
||||
var currentRole string
|
||||
var currentValue []string
|
||||
for _, part := range strings.Split(tipl[0], " ") {
|
||||
if _, ok := tiplMapping[part]; ok {
|
||||
addRole(currentRole, currentValue)
|
||||
currentRole = part
|
||||
currentValue = nil
|
||||
continue
|
||||
}
|
||||
currentValue = append(currentValue, part)
|
||||
}
|
||||
addRole(currentRole, currentValue)
|
||||
delete(tags, "tipl")
|
||||
}
|
||||
|
||||
var _ local.Extractor = (*extractor)(nil)
|
||||
|
||||
func init() {
|
||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
||||
return &extractor{fsys}
|
||||
})
|
||||
}
|
||||
17
adapters/gotaglib/gotaglib_suite_test.go
Normal file
17
adapters/gotaglib/gotaglib_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package gotaglib
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestGoTagLib(t *testing.T) {
|
||||
tests.Init(t, true)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "GoTagLib Suite")
|
||||
}
|
||||
302
adapters/gotaglib/gotaglib_test.go
Normal file
302
adapters/gotaglib/gotaglib_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package gotaglib
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Extractor", func() {
|
||||
var e *extractor
|
||||
|
||||
BeforeEach(func() {
|
||||
e = &extractor{fs: os.DirFS(".")}
|
||||
})
|
||||
|
||||
Describe("Parse", func() {
|
||||
It("correctly parses metadata from all files in folder", func() {
|
||||
mds, err := e.Parse(
|
||||
"tests/fixtures/test.mp3",
|
||||
"tests/fixtures/test.ogg",
|
||||
)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(2))
|
||||
|
||||
// Test MP3
|
||||
m := mds["tests/fixtures/test.mp3"]
|
||||
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
|
||||
Expect(m.AudioProperties.BitRate).To(Equal(192))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("compilation", []string{"1"}),
|
||||
HaveKeyWithValue("tcmp", []string{"1"})),
|
||||
)
|
||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||
|
||||
Expect(m.Tags).ToNot(HaveKey("lyrics"))
|
||||
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}), HaveKeyWithValue("lyrics:eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||
})))
|
||||
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}), HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||
})))
|
||||
|
||||
// Test OGG
|
||||
m = mds["tests/fixtures/test.ogg"]
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||
|
||||
// TagLib 1.12 returns 18, previous versions return 39.
|
||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
DescribeTable("Format-Specific tests",
|
||||
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||
file = "tests/fixtures/" + file
|
||||
mds, err := e.Parse(file)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(1))
|
||||
|
||||
m := mds[file]
|
||||
|
||||
Expect(m.HasPicture).To(Equal(image))
|
||||
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
|
||||
))
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
|
||||
))
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("tracknumber", []string{"3"}),
|
||||
HaveKeyWithValue("tracknumber", []string{"3/10"}),
|
||||
))
|
||||
if !strings.HasSuffix(file, "test.wma") {
|
||||
// TODO Not sure why this is not working for WMA
|
||||
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||
}
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("discnumber", []string{"1"}),
|
||||
HaveKeyWithValue("discnumber", []string{"1/2"}),
|
||||
))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||
|
||||
// WMA does not have a "compilation" tag, but "wm/iscompilation"
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("compilation", []string{"1"}),
|
||||
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
|
||||
)
|
||||
|
||||
if id3Lyrics {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}))
|
||||
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
}))
|
||||
} else {
|
||||
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||
"[00:00.00]This is\n[00:02.50]English",
|
||||
}))
|
||||
}
|
||||
|
||||
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||
},
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||
|
||||
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||
|
||||
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||
)
|
||||
|
||||
// Skip these tests when running as root
|
||||
Context("Access Forbidden", func() {
|
||||
var accessForbiddenFile string
|
||||
var RegularUserContext = XContext
|
||||
var isRegularUser = os.Getuid() != 0
|
||||
if isRegularUser {
|
||||
RegularUserContext = Context
|
||||
}
|
||||
|
||||
// Only run permission tests if we are not root
|
||||
RegularUserContext("when run without root privileges", func() {
|
||||
BeforeEach(func() {
|
||||
// Use root fs for absolute paths in temp directory
|
||||
e = &extractor{fs: os.DirFS("/")}
|
||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||
|
||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
Expect(f.Close()).To(Succeed())
|
||||
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||
// Strip leading slash for DirFS rooted at "/"
|
||||
_, err := e.extractMetadata(accessForbiddenFile[1:])
|
||||
Expect(err).To(MatchError(os.ErrPermission))
|
||||
})
|
||||
|
||||
It("skips the file if it cannot be read", func() {
|
||||
// Get current working directory to construct paths relative to root
|
||||
cwd, err := os.Getwd()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Strip leading slash for DirFS rooted at "/"
|
||||
files := []string{
|
||||
cwd[1:] + "/tests/fixtures/test.mp3",
|
||||
cwd[1:] + "/tests/fixtures/test.ogg",
|
||||
accessForbiddenFile[1:],
|
||||
}
|
||||
mds, err := e.Parse(files...)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(2))
|
||||
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Describe("Error Checking", func() {
|
||||
It("returns a generic ErrPath if file does not exist", func() {
|
||||
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
|
||||
_, err := e.extractMetadata(testFilePath)
|
||||
Expect(err).To(MatchError(fs.ErrNotExist))
|
||||
})
|
||||
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
|
||||
// File has an empty TDAT frame
|
||||
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("parseTIPL", func() {
|
||||
var tags map[string][]string
|
||||
|
||||
BeforeEach(func() {
|
||||
tags = make(map[string][]string)
|
||||
})
|
||||
|
||||
Context("when the TIPL string is populated", func() {
|
||||
It("correctly parses roles and names", func() {
|
||||
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
|
||||
parseTIPL(tags)
|
||||
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
|
||||
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
|
||||
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
|
||||
})
|
||||
|
||||
It("handles multiple names for a single role", func() {
|
||||
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
|
||||
parseTIPL(tags)
|
||||
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
|
||||
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||
})
|
||||
|
||||
It("discards roles without names", func() {
|
||||
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
|
||||
parseTIPL(tags)
|
||||
Expect(tags).ToNot(HaveKey("producer"))
|
||||
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the TIPL string is empty", func() {
|
||||
It("does nothing", func() {
|
||||
tags["tipl"] = []string{""}
|
||||
parseTIPL(tags)
|
||||
Expect(tags).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when the TIPL is not present", func() {
|
||||
It("does nothing", func() {
|
||||
parseTIPL(tags)
|
||||
Expect(tags).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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) {
|
||||
@@ -192,6 +214,26 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||
resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
res := make([]agents.Song, 0, len(resp))
|
||||
for _, t := range resp {
|
||||
res = append(res, agents.Song{
|
||||
Name: t.Name,
|
||||
MBID: t.MBID,
|
||||
Artist: t.Artist.Name,
|
||||
ArtistMBID: t.Artist.MBID,
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var (
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||
@@ -199,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)
|
||||
}
|
||||
@@ -239,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 {
|
||||
@@ -260,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
|
||||
@@ -290,6 +332,15 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
|
||||
return t.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
|
||||
s, err := l.client.trackGetSimilar(ctx, name, artist, count)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
|
||||
return nil, err
|
||||
}
|
||||
return s.Track, nil
|
||||
}
|
||||
|
||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
||||
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
||||
return track.Participants[role][0].Name
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -38,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() {
|
||||
@@ -71,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
|
||||
})
|
||||
@@ -101,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
|
||||
})
|
||||
@@ -144,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
|
||||
})
|
||||
@@ -177,6 +295,54 @@ var _ = Describe("lastfmAgent", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarSongsByTrack", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
BeforeEach(func() {
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
agent.client = client
|
||||
})
|
||||
|
||||
It("returns similar songs", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
|
||||
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
|
||||
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
|
||||
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
|
||||
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when no similar songs found", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call fails", func() {
|
||||
httpClient.Err = errors.New("error")
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call returns an error", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scrobbling", func() {
|
||||
var agent *lastfmAgent
|
||||
var httpClient *tests.FakeHttpClient
|
||||
@@ -184,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{
|
||||
@@ -217,7 +383,8 @@ var _ = Describe("lastfmAgent", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
sentParams, _ := url.ParseQuery(string(body))
|
||||
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||
@@ -245,7 +412,8 @@ var _ = Describe("lastfmAgent", func() {
|
||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
sentParams, _ := url.ParseQuery(string(body))
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||
})
|
||||
@@ -261,7 +429,8 @@ var _ = Describe("lastfmAgent", func() {
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
sentParams, _ := url.ParseQuery(string(body))
|
||||
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
|
||||
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
|
||||
Expect(sentParams.Get("track")).To(Equal(track.Title))
|
||||
@@ -286,7 +455,8 @@ var _ = Describe("lastfmAgent", func() {
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sentParams := httpClient.SavedRequest.URL.Query()
|
||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
||||
sentParams, _ := url.ParseQuery(string(body))
|
||||
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
|
||||
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
|
||||
})
|
||||
@@ -354,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
|
||||
})
|
||||
@@ -424,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
|
||||
@@ -485,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
|
||||
@@ -95,6 +94,19 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
||||
return &response.TopTracks, nil
|
||||
}
|
||||
|
||||
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.getSimilar")
|
||||
params.Add("track", name)
|
||||
params.Add("artist", artist)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.SimilarTracks, nil
|
||||
}
|
||||
|
||||
func (c *client) GetToken(ctx context.Context) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getToken")
|
||||
@@ -185,8 +197,15 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
||||
c.sign(params)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
var req *http.Request
|
||||
if method == http.MethodPost {
|
||||
body := strings.NewReader(params.Encode())
|
||||
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
}
|
||||
|
||||
log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
@@ -121,6 +121,30 @@ var _ = Describe("client", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("trackGetSimilar", func() {
|
||||
It("returns similar tracks for a successful response", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(len(similar.Track)).To(Equal(5))
|
||||
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
|
||||
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
|
||||
Expect(similar.Track[0].Match).To(Equal(1.0))
|
||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
||||
})
|
||||
|
||||
It("returns empty list when no similar tracks found", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
|
||||
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
|
||||
Expect(err).To(BeNil())
|
||||
Expect(similar.Track).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetToken", func() {
|
||||
It("returns a token when the request is successful", func() {
|
||||
httpClient.Res = http.Response{
|
||||
@@ -154,6 +178,74 @@ var _ = Describe("client", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("scrobble", func() {
|
||||
It("sends parameters in request body for POST", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
info := ScrobbleInfo{
|
||||
artist: "U2",
|
||||
track: "One",
|
||||
album: "Achtung Baby",
|
||||
trackNumber: 1,
|
||||
duration: 276,
|
||||
albumArtist: "U2",
|
||||
}
|
||||
err := client.scrobble(context.Background(), "SESSION_KEY", info)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
req := httpClient.SavedRequest
|
||||
Expect(req.Method).To(Equal(http.MethodPost))
|
||||
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
|
||||
Expect(req.URL.RawQuery).To(BeEmpty())
|
||||
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
bodyParams, _ := url.ParseQuery(string(body))
|
||||
Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
|
||||
Expect(bodyParams.Get("artist")).To(Equal("U2"))
|
||||
Expect(bodyParams.Get("track")).To(Equal("One"))
|
||||
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
|
||||
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
|
||||
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("updateNowPlaying", func() {
|
||||
It("sends parameters in request body for POST", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
info := ScrobbleInfo{
|
||||
artist: "U2",
|
||||
track: "One",
|
||||
album: "Achtung Baby",
|
||||
trackNumber: 1,
|
||||
duration: 276,
|
||||
albumArtist: "U2",
|
||||
}
|
||||
err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
|
||||
Expect(err).To(BeNil())
|
||||
|
||||
req := httpClient.SavedRequest
|
||||
Expect(req.Method).To(Equal(http.MethodPost))
|
||||
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
|
||||
Expect(req.URL.RawQuery).To(BeEmpty())
|
||||
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
bodyParams, _ := url.ParseQuery(string(body))
|
||||
Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
||||
Expect(bodyParams.Get("artist")).To(Equal("U2"))
|
||||
Expect(bodyParams.Get("track")).To(Equal("One"))
|
||||
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
|
||||
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
|
||||
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("sign", func() {
|
||||
It("adds an api_sig param with the signature", func() {
|
||||
params := url.Values{}
|
||||
|
||||
@@ -5,6 +5,7 @@ type Response struct {
|
||||
SimilarArtists SimilarArtists `json:"similarartists"`
|
||||
TopTracks TopTracks `json:"toptracks"`
|
||||
Album Album `json:"album"`
|
||||
SimilarTracks SimilarTracks `json:"similartracks"`
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token"`
|
||||
@@ -59,6 +60,28 @@ type TopTracks struct {
|
||||
Attr Attr `json:"@attr"`
|
||||
}
|
||||
|
||||
type SimilarTracks struct {
|
||||
Track []SimilarTrack `json:"track"`
|
||||
Attr SimilarAttr `json:"@attr"`
|
||||
}
|
||||
|
||||
type SimilarTrack struct {
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid"`
|
||||
Match float64 `json:"match"`
|
||||
Artist SimilarTrackArtist `json:"artist"`
|
||||
}
|
||||
|
||||
type SimilarTrackArtist struct {
|
||||
Name string `json:"name"`
|
||||
MBID string `json:"mbid"`
|
||||
}
|
||||
|
||||
type SimilarAttr struct {
|
||||
Artist string `json:"artist"`
|
||||
Track string `json:"track"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -151,11 +151,7 @@ var _ = Describe("Extractor", func() {
|
||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||
unsUslt := makeLyrics("xxx", "unspecified")
|
||||
|
||||
// Why is the order inconsistent between runs? Nobody knows
|
||||
Expect(lyrics).To(Or(
|
||||
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
|
||||
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
|
||||
))
|
||||
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||
})
|
||||
|
||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||
|
||||
@@ -168,7 +168,7 @@ func parseTIPL(tags map[string][]string) {
|
||||
var _ local.Extractor = (*extractor)(nil)
|
||||
|
||||
func init() {
|
||||
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||
// ignores fs, as taglib extractor only works with local files
|
||||
return &extractor{baseDir}
|
||||
})
|
||||
|
||||
@@ -80,12 +80,11 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||
|
||||
// TabLib 1.12 returns 18, previous versions return 39.
|
||||
// TagLib 1.12 returns 18, previous versions return 39.
|
||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -106,7 +105,7 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
// Import adapters to register them
|
||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
|
||||
import (
|
||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package conf
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -57,6 +58,7 @@ type configOptions struct {
|
||||
AutoTranscodeDownload bool
|
||||
DefaultDownsamplingFormat string
|
||||
SearchFullString bool
|
||||
SimilarSongsMatchThreshold int
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
IgnoredArticles string
|
||||
@@ -126,6 +128,7 @@ type configOptions struct {
|
||||
DevExternalScanner bool
|
||||
DevScannerThreads uint
|
||||
DevSelectiveWatcher bool
|
||||
DevLegacyEmbedImage bool
|
||||
DevInsightsInitialDelay time.Duration
|
||||
DevEnablePlayerInsights bool
|
||||
DevEnablePluginsInsights bool
|
||||
@@ -152,6 +155,7 @@ type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
EnableAverageRating bool
|
||||
LegacyClients string
|
||||
MinimalClients string
|
||||
}
|
||||
@@ -171,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 {
|
||||
@@ -181,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 {
|
||||
@@ -366,10 +376,16 @@ func Load(noConfigDump bool) {
|
||||
disableExternalServices()
|
||||
}
|
||||
|
||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
// 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 +474,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
|
||||
@@ -557,6 +589,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("autotranscodedownload", false)
|
||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||
viper.SetDefault("searchfullstring", false)
|
||||
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
@@ -609,17 +642,19 @@ func setViperDefaults() {
|
||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||
viper.SetDefault("subsonic.artistparticipations", false)
|
||||
viper.SetDefault("subsonic.defaultreportrealpath", false)
|
||||
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
|
||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||
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.baseurl", "https://api.listenbrainz.org/1/")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
@@ -634,7 +669,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
|
||||
|
||||
@@ -22,6 +22,8 @@ type PluginLoader interface {
|
||||
LoadMediaAgent(name string) (Interface, bool)
|
||||
}
|
||||
|
||||
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
|
||||
// until one returns valid data.
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
@@ -129,26 +131,14 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
|
||||
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
return "", ErrNotFound
|
||||
}
|
||||
mbid, err := retriever.GetArtistMBID(ctx, id, name)
|
||||
if mbid != "" && err == nil {
|
||||
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
|
||||
return mbid, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
return retriever.GetArtistMBID(ctx, id, name)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
@@ -158,26 +148,14 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
|
||||
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
return "", ErrNotFound
|
||||
}
|
||||
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
if url != "" && err == nil {
|
||||
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
return retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
@@ -187,26 +165,14 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||
case consts.VariousArtistsID:
|
||||
return "", nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
|
||||
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
return "", ErrNotFound
|
||||
}
|
||||
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
|
||||
return bio, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNotFound
|
||||
return retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
||||
@@ -254,26 +220,14 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
|
||||
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
|
||||
return images, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
return retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
})
|
||||
}
|
||||
|
||||
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
||||
@@ -288,80 +242,127 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||
|
||||
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
||||
if len(songs) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
|
||||
return songs, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||
if name == consts.UnknownAlbum {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
|
||||
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
|
||||
retriever, ok := ag.(AlbumInfoRetriever)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
if err == nil {
|
||||
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||
"mbid", mbid, "elapsed", time.Since(start))
|
||||
return album, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
|
||||
if name == consts.UnknownAlbum {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(AlbumImageRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSimilarSongsByTrack returns similar songs for a given track.
|
||||
func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByTrackRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSimilarSongsByAlbum returns similar songs for a given album.
|
||||
func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
}
|
||||
|
||||
// GetSimilarSongsByArtist returns similar songs for a given artist.
|
||||
func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) {
|
||||
switch id {
|
||||
case consts.UnknownArtistID:
|
||||
return nil, ErrNotFound
|
||||
case consts.VariousArtistsID:
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByArtistRetriever)
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
|
||||
})
|
||||
}
|
||||
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
var zero T
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
ag := a.getAgent(enabledAgent)
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
retriever, ok := ag.(AlbumImageRetriever)
|
||||
if !ok {
|
||||
result, err := fn(ag)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
|
||||
|
||||
if result != zero {
|
||||
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
|
||||
return result, nil
|
||||
}
|
||||
if len(images) > 0 && err == nil {
|
||||
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
|
||||
"mbid", mbid, "elapsed", time.Since(start))
|
||||
return images, nil
|
||||
}
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
|
||||
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
results, err := fn(ag)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
@@ -376,3 +377,6 @@ var _ ArtistImageRetriever = (*Agents)(nil)
|
||||
var _ ArtistTopSongsRetriever = (*Agents)(nil)
|
||||
var _ AlbumInfoRetriever = (*Agents)(nil)
|
||||
var _ AlbumImageRetriever = (*Agents)(nil)
|
||||
var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
|
||||
var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
|
||||
var _ SimilarSongsByArtistRetriever = (*Agents)(nil)
|
||||
|
||||
@@ -295,6 +295,72 @@ var _ = Describe("Agents", func() {
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarSongsByTrack", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
|
||||
Name: "Similar Song",
|
||||
MBID: "mbid555",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarSongsByAlbum", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
|
||||
Name: "Album Similar Song",
|
||||
MBID: "mbid666",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetSimilarSongsByArtist", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
|
||||
Name: "Artist Similar Song",
|
||||
MBID: "mbid777",
|
||||
}}))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
|
||||
})
|
||||
It("skips the agent if it returns an error", func() {
|
||||
mock.Err = errors.New("error")
|
||||
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
|
||||
})
|
||||
It("interrupts if the context is canceled", func() {
|
||||
cancel()
|
||||
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
Expect(mock.Args).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -377,6 +443,39 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, name, artist, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Song{{
|
||||
Name: "Similar Song",
|
||||
MBID: "mbid555",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, name, artist, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Song{{
|
||||
Name: "Album Similar Song",
|
||||
MBID: "mbid666",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []interface{}{id, name, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
return []Song{{
|
||||
Name: "Artist Similar Song",
|
||||
MBID: "mbid777",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
type emptyAgent struct {
|
||||
Interface
|
||||
}
|
||||
|
||||
@@ -33,9 +33,15 @@ type ExternalImage struct {
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ID string
|
||||
Name string
|
||||
MBID string
|
||||
ISRC string
|
||||
Artist string
|
||||
ArtistMBID string
|
||||
Album string
|
||||
AlbumMBID string
|
||||
Duration uint32 // Duration in milliseconds, 0 means unknown
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -76,6 +82,41 @@ type ArtistTopSongsRetriever interface {
|
||||
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
// SimilarSongsByTrackRetriever provides similar songs based on a specific track
|
||||
type SimilarSongsByTrackRetriever interface {
|
||||
// GetSimilarSongsByTrack returns songs similar to the given track.
|
||||
// Parameters:
|
||||
// - id: local mediafile ID
|
||||
// - name: track title
|
||||
// - artist: artist name
|
||||
// - mbid: MusicBrainz recording ID (may be empty)
|
||||
// - count: maximum number of results
|
||||
GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
// SimilarSongsByAlbumRetriever provides similar songs based on an album
|
||||
type SimilarSongsByAlbumRetriever interface {
|
||||
// GetSimilarSongsByAlbum returns songs similar to tracks on the given album.
|
||||
// Parameters:
|
||||
// - id: local album ID
|
||||
// - name: album name
|
||||
// - artist: album artist name
|
||||
// - mbid: MusicBrainz release ID (may be empty)
|
||||
// - count: maximum number of results
|
||||
GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
// SimilarSongsByArtistRetriever provides similar songs based on an artist
|
||||
type SimilarSongsByArtistRetriever interface {
|
||||
// GetSimilarSongsByArtist returns songs similar to the artist's catalog.
|
||||
// Parameters:
|
||||
// - id: local artist ID
|
||||
// - name: artist name
|
||||
// - mbid: MusicBrainz artist ID (may be empty)
|
||||
// - count: maximum number of results
|
||||
GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error)
|
||||
}
|
||||
|
||||
var Map map[string]Constructor
|
||||
|
||||
func Register(name string, init Constructor) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/resources"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
|
||||
@@ -84,6 +86,13 @@ var picTypeRegexes = []*regexp.Regexp{
|
||||
}
|
||||
|
||||
func fromTag(ctx context.Context, path string) sourceFunc {
|
||||
if conf.Server.DevLegacyEmbedImage {
|
||||
return fromTagLegacy(ctx, path)
|
||||
}
|
||||
return fromTagGoTaglib(ctx, path)
|
||||
}
|
||||
|
||||
func fromTagLegacy(ctx context.Context, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
@@ -128,6 +137,44 @@ func fromTag(ctx context.Context, path string) sourceFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func fromTagGoTaglib(ctx context.Context, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
images := f.Properties().Images
|
||||
if len(images) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded image found in %s", path)
|
||||
}
|
||||
|
||||
imageIndex := findBestImageIndex(ctx, images, path)
|
||||
data, err := f.Image(imageIndex)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), path, nil
|
||||
}
|
||||
}
|
||||
|
||||
func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int {
|
||||
for _, regex := range picTypeRegexes {
|
||||
for i, img := range images {
|
||||
if regex.MatchString(img.Type) {
|
||||
log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
|
||||
return 0
|
||||
}
|
||||
|
||||
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
if path == "" {
|
||||
|
||||
29
core/external/extdata_helper_test.go
vendored
29
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))
|
||||
@@ -282,3 +287,27 @@ func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid stri
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, name, artist, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, name, artist, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, name, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
205
core/external/provider.go
vendored
205
core/external/provider.go
vendored
@@ -32,7 +32,7 @@ const (
|
||||
type Provider interface {
|
||||
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
||||
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
@@ -80,6 +80,9 @@ type Agents interface {
|
||||
agents.ArtistSimilarRetriever
|
||||
agents.ArtistTopSongsRetriever
|
||||
agents.ArtistURLRetriever
|
||||
agents.SimilarSongsByTrackRetriever
|
||||
agents.SimilarSongsByAlbumRetriever
|
||||
agents.SimilarSongsByArtistRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
@@ -256,7 +259,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
_ = g.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -275,22 +278,54 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var songs []agents.Song
|
||||
|
||||
// Try entity-specific similarity first
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
|
||||
case *model.Album:
|
||||
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
|
||||
case *model.Artist:
|
||||
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
|
||||
default:
|
||||
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matchSongsToLibrary(ctx, songs, count)
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
return e.similarSongsFallback(ctx, id, count)
|
||||
}
|
||||
|
||||
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
|
||||
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
|
||||
// a weighted random selection of songs to return as similar songs.
|
||||
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
|
||||
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
@@ -422,21 +457,20 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
// Enrich songs with artist info if not already present (for top songs, we know the artist)
|
||||
for i := range songs {
|
||||
if songs[i].Artist == "" {
|
||||
songs[i].Artist = artistName
|
||||
}
|
||||
if songs[i].ArtistMBID == "" {
|
||||
songs[i].ArtistMBID = artist.MbzArtistID
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count)
|
||||
mfs, err := e.matchSongsToLibrary(ctx, songs, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||
@@ -447,137 +481,6 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
titleMap := map[string]string{}
|
||||
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 != "" {
|
||||
continue
|
||||
}
|
||||
sanitized := str.SanitizeFieldForSorting(s.Name)
|
||||
titleMap[sanitized] = s.Name
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(titleMap) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
titleFilters := squirrel.Or{}
|
||||
for sanitized := range titleMap {
|
||||
titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
|
||||
}
|
||||
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Or{
|
||||
squirrel.Eq{"artist_id": artist.ID},
|
||||
squirrel.Eq{"album_artist_id": artist.ID},
|
||||
},
|
||||
titleFilters,
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
sanitized := str.SanitizeFieldForSorting(mf.Title)
|
||||
if _, ok := matches[sanitized]; !ok {
|
||||
matches[sanitized] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
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
|
||||
}
|
||||
}
|
||||
// Try MBID match second
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall back to title match
|
||||
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
@@ -614,7 +517,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
|
||||
}
|
||||
}
|
||||
|
||||
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
artistName := artist.Name()
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
|
||||
|
||||
205
core/external/provider_artistradio_test.go
vendored
205
core/external/provider_artistradio_test.go
vendored
@@ -1,205 +0,0 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - ArtistRadio", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var mockAgent *mockSimilarArtistAgent
|
||||
var mockTopAgent agents.ArtistTopSongsRetriever
|
||||
var mockSimilarAgent agents.ArtistSimilarRetriever
|
||||
var agentsCombined Agents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
It("returns similar songs from main artist and similar artists", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
||||
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
similarAgentsResp := []agents.Artist{
|
||||
{Name: "Similar Artist", MBID: "similar-mbid"},
|
||||
}
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(similarAgentsResp, nil).Once()
|
||||
|
||||
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
|
||||
// MBID lookup returns empty (no match)
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
// Name lookup returns the similar artist
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Or)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song Three", MBID: "mbid-3"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3))
|
||||
for _, song := range songs {
|
||||
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when artist is not found", func() {
|
||||
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
|
||||
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns songs from main artist when GetSimilarArtists returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(nil, errors.New("error getting similar artists")).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("returns empty list when GetArtistTopSongs returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, errors.New("error getting top songs")).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("respects count parameter", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
||||
})
|
||||
})
|
||||
504
core/external/provider_matching.go
vendored
Normal file
504
core/external/provider_matching.go
vendored
Normal file
@@ -0,0 +1,504 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
// 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 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. 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 > ISRC > Title+Artist.
|
||||
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
||||
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||
// 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
|
||||
// 4. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// Example 1 - MBID Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (MBID match takes priority over title+artist)
|
||||
//
|
||||
// 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: [
|
||||
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
|
||||
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
|
||||
// ]
|
||||
// Result: t2 (Level 3 beats Level 1 due to album match)
|
||||
//
|
||||
// Example 4 - Fuzzy Title Matching:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
// With threshold=85%: Match succeeds (similarity ~0.87)
|
||||
// With threshold=100%: No match (not exact)
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - ctx: Context for database operations
|
||||
// - songs: Slice of agent.Song results from external providers
|
||||
// - count: Maximum number of matches to return
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// Returns up to 'count' MediaFiles from the library that best match the input songs,
|
||||
// preserving the original order from the agent. Songs that cannot be matched are skipped.
|
||||
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
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, 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.
|
||||
// It extracts all non-empty ID fields from the input songs and performs a single
|
||||
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
|
||||
// Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
|
||||
// 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, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" && !songMatchedIn(s, priorMatches...) {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
type songQuery struct {
|
||||
title string // Sanitized song title
|
||||
artist string // Sanitized artist name (without articles like "The")
|
||||
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
||||
album string // Sanitized album name (optional, for specificity scoring)
|
||||
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
|
||||
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
|
||||
}
|
||||
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
||||
type matchScore struct {
|
||||
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 > 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
|
||||
}
|
||||
return s.albumSimilarity > other.albumSimilarity
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// Higher values indicate more specific matches (MBIDs > names > title only).
|
||||
// Uses fuzzy matching for album names with the same threshold as title matching.
|
||||
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
|
||||
title := str.SanitizeFieldForSorting(mf.Title)
|
||||
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
|
||||
album := str.SanitizeFieldForSorting(mf.Album)
|
||||
|
||||
// Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
if q.artistMBID != "" && q.albumMBID != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
|
||||
return 5
|
||||
}
|
||||
// Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
if q.artistMBID != "" && q.album != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
|
||||
return 4
|
||||
}
|
||||
// Level 3: Title + Artist name + Album name (fuzzy)
|
||||
if q.artist != "" && q.album != "" &&
|
||||
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
|
||||
return 3
|
||||
}
|
||||
// Level 2: Title + Artist MBID
|
||||
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
|
||||
return 2
|
||||
}
|
||||
// Level 1: Title + Artist name
|
||||
if q.artist != "" && artist == q.artist {
|
||||
return 1
|
||||
}
|
||||
// Level 0: Title only match (but for fuzzy, title matched via similarity)
|
||||
// Check if at least the title matches exactly
|
||||
if title == q.title {
|
||||
return 0
|
||||
}
|
||||
return -1 // No exact title match, but could still be a fuzzy match
|
||||
}
|
||||
|
||||
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
|
||||
// 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, 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
|
||||
}
|
||||
|
||||
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
|
||||
|
||||
// Group queries by artist for efficient DB access
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
if q.artist != "" {
|
||||
byArtist[q.artist] = append(byArtist[q.artist], q)
|
||||
}
|
||||
}
|
||||
|
||||
matches := map[string]model.MediaFile{}
|
||||
for artist, artistQueries := range byArtist {
|
||||
// Single DB query per artist - get all their tracks
|
||||
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"order_artist_name": artist},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best match for each query using unified scoring
|
||||
for _, q := range artistQueries {
|
||||
if mf, found := e.findBestMatch(q, tracks, threshold); found {
|
||||
key := q.title + "|" + q.artist
|
||||
if _, exists := matches[key]; !exists {
|
||||
matches[key] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// 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 1.0 // Unknown duration — don't penalise
|
||||
}
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
|
||||
return 1.0 / (1.0 + diff)
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
||||
// 1. Highest title similarity
|
||||
// 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) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
||||
for _, mf := range tracks {
|
||||
trackTitle := str.SanitizeFieldForSorting(mf.Title)
|
||||
titleSim := similarityRatio(q.title, trackTitle)
|
||||
|
||||
if titleSim < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute album similarity for tiebreaking (0.0 if no album in query)
|
||||
var albumSim float64
|
||||
if q.album != "" {
|
||||
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
|
||||
albumSim = similarityRatio(q.album, trackAlbum)
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
durationProximity: durationProximity(q.durationMs, mf.Duration),
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, mf, threshold),
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = mf
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return bestMatch, found
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if songMatchedIn(s, priorMatches...) {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, songQuery{
|
||||
title: str.SanitizeFieldForSorting(s.Name),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
|
||||
artistMBID: s.ArtistMBID,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
durationMs: s.Duration,
|
||||
})
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
|
||||
func similarityRatio(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1.0
|
||||
}
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
|
||||
return smetrics.JaroWinkler(a, b, 0.7, 4)
|
||||
}
|
||||
57
core/external/provider_matching_internal_test.go
vendored
Normal file
57
core/external/provider_matching_internal_test.go
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("similarityRatio", func() {
|
||||
It("returns 1.0 for identical strings", func() {
|
||||
Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
|
||||
})
|
||||
|
||||
It("returns 0.0 for empty strings", func() {
|
||||
Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
|
||||
Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
|
||||
})
|
||||
|
||||
It("returns high similarity for remastered suffix", func() {
|
||||
// Jaro-Winkler gives ~0.92 for this case
|
||||
ratio := similarityRatio("paranoid android", "paranoid android remastered")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns high similarity for suffix additions like (Live)", func() {
|
||||
// Jaro-Winkler gives ~0.96 for this case
|
||||
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.90))
|
||||
})
|
||||
|
||||
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
|
||||
// Jaro-Winkler gives ~0.90 because of common prefix
|
||||
ratio := similarityRatio("yesterday", "yesterday once more")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns low similarity for same suffix", func() {
|
||||
// Jaro-Winkler gives ~0.70 for this case
|
||||
ratio := similarityRatio("postman (live)", "taxman (live)")
|
||||
Expect(ratio).To(BeNumerically("<", 0.85))
|
||||
})
|
||||
|
||||
It("handles unicode characters", func() {
|
||||
ratio := similarityRatio("dont stop believin", "don't stop believin'")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns low similarity for completely different strings", func() {
|
||||
ratio := similarityRatio("abc", "xyz")
|
||||
Expect(ratio).To(BeNumerically("<", 0.5))
|
||||
})
|
||||
|
||||
It("is symmetric", func() {
|
||||
ratio1 := similarityRatio("hello world", "hello")
|
||||
ratio2 := similarityRatio("hello", "hello world")
|
||||
Expect(ratio1).To(Equal(ratio2))
|
||||
})
|
||||
})
|
||||
762
core/external/provider_matching_test.go
vendored
Normal file
762
core/external/provider_matching_test.go
vendored
Normal file
@@ -0,0 +1,762 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - Song Matching", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var agentsCombined *mockAgents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var albumRepo *mockAlbumRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
albumRepo = newMockAlbumRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
|
||||
agentsCombined = &mockAgents{}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
|
||||
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(returnedSongs, nil).Once()
|
||||
|
||||
// loadTracksByTitleAndArtist - queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Describe("matchSongsToLibrary priority matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""}
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) {
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(returnedSongs, nil).Once()
|
||||
|
||||
// loadTracksByID
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(idMatches, nil).Once()
|
||||
|
||||
// loadTracksByMBID
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasMBID := eq["mbz_recording_id"]
|
||||
return hasMBID
|
||||
})).Return(mbidMatches, nil).Once()
|
||||
|
||||
// loadTracksByTitleAndArtist - now queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(artistTracks, nil).Maybe()
|
||||
}
|
||||
|
||||
Context("when agent returns artist and album metadata", func() {
|
||||
It("matches by title + artist MBID + album MBID (highest priority)", func() {
|
||||
// Song in library with all MBIDs
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
|
||||
}
|
||||
// Another song with same title but different MBIDs (should NOT match)
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
|
||||
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
|
||||
}
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist name + album name when MBIDs unavailable", func() {
|
||||
// Song in library without MBIDs but with matching artist/album names
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
|
||||
}
|
||||
// Another song with same title but different artist (should NOT match)
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("matches by title + artist only when album info unavailable", func() {
|
||||
// Song in library with matching artist
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
|
||||
}
|
||||
// Another song with same title but different artist
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
|
||||
}
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Depeche Mode"}, // No album info
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct-match"))
|
||||
})
|
||||
|
||||
It("does not match songs without artist info", func() {
|
||||
// Songs without artist info cannot be matched since we query by artist
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song"}, // No artist/album info at all
|
||||
}
|
||||
|
||||
// No artist to query, so no GetAll calls for title matching
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when matching multiple songs with the same title but different artists", func() {
|
||||
It("returns distinct matches for each artist's version (covers scenario)", func() {
|
||||
// Multiple covers of the same song by different artists
|
||||
cover1 := model.MediaFile{
|
||||
ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
|
||||
}
|
||||
cover2 := model.MediaFile{
|
||||
ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits",
|
||||
}
|
||||
cover3 := model.MediaFile{
|
||||
ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
|
||||
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
|
||||
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// All three covers should be returned, not just the first one
|
||||
Expect(songs).To(HaveLen(3))
|
||||
// Verify all three different versions are included
|
||||
ids := []string{songs[0].ID, songs[1].ID, songs[2].ID}
|
||||
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when matching multiple songs with different precision levels", func() {
|
||||
It("prefers more precise matches for each song", func() {
|
||||
// Library has multiple versions of same song
|
||||
preciseMatch := model.MediaFile{
|
||||
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
|
||||
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
|
||||
}
|
||||
lessAccurateMatch := model.MediaFile{
|
||||
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
|
||||
MbzArtistID: "mbid-1",
|
||||
}
|
||||
artistTwoMatch := model.MediaFile{
|
||||
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
|
||||
}
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
|
||||
{Name: "Song B", Artist: "Artist Two"}, // Different artist
|
||||
}
|
||||
|
||||
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
// First song should be the precise match (has all MBIDs)
|
||||
Expect(songs[0].ID).To(Equal("precise"))
|
||||
// Second song matches by title + artist
|
||||
Expect(songs[1].ID).To(Equal("artist-two"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Fuzzy matching fallback", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
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()
|
||||
})
|
||||
|
||||
Context("with default threshold (85%)", func() {
|
||||
It("matches songs with remastered suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
// Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered"
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
// Artist catalog has the remastered version (fuzzy match will find it)
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("remastered"))
|
||||
})
|
||||
|
||||
It("matches songs with live suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("live"))
|
||||
})
|
||||
|
||||
It("does not match completely different songs", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Yesterday", Artist: "The Beatles"},
|
||||
}
|
||||
// Artist catalog has completely different songs
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
|
||||
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with threshold set to 100 (exact match only)", func() {
|
||||
It("only matches exact titles", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Paranoid Android", Artist: "Radiohead"},
|
||||
}
|
||||
// Artist catalog has only remastered version - no exact match
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with lower threshold (75%)", func() {
|
||||
It("matches more aggressively", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 75
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Song", Artist: "Artist"},
|
||||
}
|
||||
artistTracks := model.MediaFiles{
|
||||
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, artistTracks)
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("extended"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with fuzzy album matching", func() {
|
||||
It("matches album with (Remaster) suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
// Agent returns "A Night at the Opera" but library has remastered version
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
|
||||
}
|
||||
// Library has same album with remaster suffix
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
// Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1)
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("matches album with (Deluxe Edition) suffix", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
wrongMatch := model.MediaFile{
|
||||
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
It("prefers exact album match over fuzzy album match", func() {
|
||||
conf.Server.SimilarSongsMatchThreshold = 85
|
||||
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
|
||||
}
|
||||
exactMatch := model.MediaFile{
|
||||
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
|
||||
}
|
||||
fuzzyMatch := model.MediaFile{
|
||||
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
// Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity)
|
||||
Expect(songs[0].ID).To(Equal("exact"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Duration matching", func() {
|
||||
var track model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
Context("when agent provides duration", func() {
|
||||
It("prefers tracks with matching duration", func() {
|
||||
// Agent returns song with duration 180000ms (180 seconds)
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
|
||||
}
|
||||
// Library has two versions: one matching duration, one not
|
||||
correctMatch := model.MediaFile{
|
||||
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
wrongDuration := model.MediaFile{
|
||||
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("correct"))
|
||||
})
|
||||
|
||||
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.5 seconds (close to target)
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
|
||||
}
|
||||
|
||||
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("close-duration"))
|
||||
})
|
||||
|
||||
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 close, one far
|
||||
closeDuration := model.MediaFile{
|
||||
ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
|
||||
}
|
||||
farDuration := model.MediaFile{
|
||||
ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
|
||||
}
|
||||
|
||||
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("close"))
|
||||
})
|
||||
|
||||
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},
|
||||
}
|
||||
// Library only has tracks with very different duration
|
||||
differentDuration := model.MediaFile{
|
||||
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// 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("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 (wins on title similarity)
|
||||
differentTitle := model.MediaFile{
|
||||
ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0,
|
||||
}
|
||||
correctTitle := model.MediaFile{
|
||||
ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// 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"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when agent does not provide duration", func() {
|
||||
It("matches without duration filtering (duration=0)", func() {
|
||||
// Agent returns song without duration
|
||||
returnedSongs := []agents.Song{
|
||||
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
|
||||
}
|
||||
// Library tracks with various durations should all be candidates
|
||||
anyTrack := model.MediaFile{
|
||||
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("any"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
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},
|
||||
}
|
||||
shortTrack := model.MediaFile{
|
||||
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
|
||||
}
|
||||
|
||||
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("short"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
443
core/external/provider_similarsongs_test.go
vendored
Normal file
443
core/external/provider_similarsongs_test.go
vendored
Normal file
@@ -0,0 +1,443 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
var _ = Describe("Provider - SimilarSongs", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var mockAgent *mockSimilarArtistAgent
|
||||
var mockTopAgent agents.ArtistTopSongsRetriever
|
||||
var mockSimilarAgent agents.ArtistSimilarRetriever
|
||||
var agentsCombined *mockAgents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var albumRepo *mockAlbumRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
albumRepo = newMockAlbumRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
Describe("dispatch by entity type", func() {
|
||||
Context("when ID is a MediaFile (track)", func() {
|
||||
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
|
||||
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
|
||||
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
|
||||
|
||||
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
|
||||
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()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(model.MediaFiles{}, nil).Once()
|
||||
|
||||
// Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasMBID := eq["mbz_recording_id"]
|
||||
return hasMBID
|
||||
})).Return(model.MediaFiles{}, nil).Maybe()
|
||||
|
||||
// Mock loadTracksByTitleAndArtist - queries by artist name
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 2 {
|
||||
return false
|
||||
}
|
||||
eq, hasEq := and[0].(squirrel.Eq)
|
||||
if !hasEq {
|
||||
return false
|
||||
}
|
||||
_, hasArtist := eq["order_artist_name"]
|
||||
return hasArtist
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
|
||||
It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
|
||||
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
|
||||
artist := model.Artist{ID: "artist-1", Name: "Artist"}
|
||||
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
|
||||
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
|
||||
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()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{}, nil).Once()
|
||||
|
||||
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
|
||||
// and recursively calls getArtist(v.ArtistID)
|
||||
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()
|
||||
|
||||
// Then it recurses with the artist-1 ID
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Album", func() {
|
||||
It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
|
||||
album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
|
||||
matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
|
||||
|
||||
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
|
||||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(model.MediaFiles{}, nil).Once()
|
||||
|
||||
// Mock loadTracksByMBID - MBID match
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
return false
|
||||
}
|
||||
_, hasEq := and[0].(squirrel.Eq)
|
||||
return hasEq
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
|
||||
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
|
||||
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
|
||||
artist := model.Artist{ID: "artist-1", Name: "Artist"}
|
||||
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
|
||||
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
|
||||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
||||
|
||||
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
|
||||
Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
|
||||
// and recursively calls getArtist(v.AlbumArtistID)
|
||||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
||||
|
||||
// Then it recurses with the artist-1 ID
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Maybe()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
||||
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when ID is an Artist", func() {
|
||||
It("calls GetSimilarSongsByArtist and returns matched songs", func() {
|
||||
artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
|
||||
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"},
|
||||
}, nil).Once()
|
||||
|
||||
// Mock loadTracksByID - no ID matches
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return ok
|
||||
})).Return(model.MediaFiles{}, nil).Once()
|
||||
|
||||
// Mock loadTracksByMBID - MBID match
|
||||
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
and, ok := opt.Filters.(squirrel.And)
|
||||
if !ok || len(and) < 1 {
|
||||
return false
|
||||
}
|
||||
_, hasEq := and[0].(squirrel.Eq)
|
||||
return hasEq
|
||||
})).Return(model.MediaFiles{matchedSong}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
It("returns similar songs from main artist and similar artists", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
||||
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Once()
|
||||
|
||||
// New similar songs by artist returns ErrNotFound to trigger fallback
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
similarAgentsResp := []agents.Artist{
|
||||
{Name: "Similar Artist", MBID: "similar-mbid"},
|
||||
}
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(similarAgentsResp, nil).Once()
|
||||
|
||||
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
|
||||
// MBID lookup returns empty (no match)
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Eq)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
// Name lookup returns the similar artist
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
_, ok := opt.Filters.(squirrel.Or)
|
||||
return opt.Max == 0 && ok
|
||||
})).Return(model.Artists{similarArtist}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song Three", MBID: "mbid-3"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(3))
|
||||
for _, song := range songs {
|
||||
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when artist is not found", func() {
|
||||
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
|
||||
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns songs from main artist when GetSimilarArtists returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// New similar songs by artist returns ErrNotFound to trigger fallback
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return(nil, errors.New("error getting similar artists")).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
|
||||
It("returns empty list when GetArtistTopSongs returns error", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// New similar songs by artist returns ErrNotFound to trigger fallback
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, errors.New("error getting top songs")).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("respects count parameter", func() {
|
||||
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
||||
|
||||
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 1 && opt.Filters != nil
|
||||
})).Return(model.Artists{artist1}, nil).Maybe()
|
||||
|
||||
// New similar songs by artist returns ErrNotFound to trigger fallback
|
||||
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
||||
Return([]agents.Artist{}, nil).Once()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).Return(model.Artists{}, nil).Once()
|
||||
|
||||
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
||||
Return([]agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-1"},
|
||||
{Name: "Song Two", MBID: "mbid-2"},
|
||||
}, nil).Once()
|
||||
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
||||
})
|
||||
})
|
||||
6
core/external/provider_topsongs_test.go
vendored
6
core/external/provider_topsongs_test.go
vendored
@@ -7,6 +7,8 @@ import (
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -26,6 +28,10 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo() // Use helper mock
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
|
||||
|
||||
@@ -215,6 +215,7 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Config.BackupCount = conf.Server.Backup.Count
|
||||
data.Config.DevActivityPanel = conf.Server.DevActivityPanel
|
||||
data.Config.ScannerEnabled = conf.Server.Scanner.Enabled
|
||||
data.Config.ScannerExtractor = conf.Server.Scanner.Extractor
|
||||
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
|
||||
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
|
||||
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
|
||||
@@ -265,6 +266,10 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading active users count", err)
|
||||
}
|
||||
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Error reading file suffixes count", err)
|
||||
}
|
||||
|
||||
// Check for smart playlists
|
||||
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)
|
||||
|
||||
@@ -40,12 +40,14 @@ type Data struct {
|
||||
Libraries int64 `json:"libraries"`
|
||||
ActiveUsers int64 `json:"activeUsers"`
|
||||
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
|
||||
FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
|
||||
} `json:"library"`
|
||||
Config struct {
|
||||
LogLevel string `json:"logLevel,omitempty"`
|
||||
LogFileConfigured bool `json:"logFileConfigured,omitempty"`
|
||||
TLSConfigured bool `json:"tlsConfigured,omitempty"`
|
||||
ScannerEnabled bool `json:"scannerEnabled,omitempty"`
|
||||
ScannerExtractor string `json:"scannerExtractor,omitempty"`
|
||||
ScanSchedule string `json:"scanSchedule,omitempty"`
|
||||
ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"`
|
||||
ScanOnStartup bool `json:"scanOnStartup,omitempty"`
|
||||
|
||||
@@ -168,13 +168,20 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
var mfs model.MediaFiles
|
||||
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
|
||||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||||
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
|
||||
filteredLines := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line := strings.TrimSpace(line)
|
||||
@@ -201,33 +208,66 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
|
||||
// See https://github.com/navidrome/navidrome/issues/4663
|
||||
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
|
||||
return strings.ToLower(norm.NFD.String(path))
|
||||
})
|
||||
// SQLite comparisons do not perform Unicode normalization, and filesystem normalization
|
||||
// differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC).
|
||||
// Generate lookup candidates for both forms so playlist entries match DB paths regardless
|
||||
// of the original normalization. See https://github.com/navidrome/navidrome/issues/4884
|
||||
//
|
||||
// We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE
|
||||
// only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters
|
||||
// (e.g., ABCD vs abcd) are not matched case-insensitively by NOCASE.
|
||||
lookupCandidates := make([]string, 0, len(resolvedPaths)*4)
|
||||
seen := make(map[string]struct{}, len(resolvedPaths)*4)
|
||||
for _, path := range resolvedPaths {
|
||||
// Add original paths first (for exact matching of non-ASCII characters)
|
||||
nfcRaw := norm.NFC.String(path)
|
||||
if _, ok := seen[nfcRaw]; !ok {
|
||||
seen[nfcRaw] = struct{}{}
|
||||
lookupCandidates = append(lookupCandidates, nfcRaw)
|
||||
}
|
||||
nfdRaw := norm.NFD.String(path)
|
||||
if _, ok := seen[nfdRaw]; !ok {
|
||||
seen[nfdRaw] = struct{}{}
|
||||
lookupCandidates = append(lookupCandidates, nfdRaw)
|
||||
}
|
||||
|
||||
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
|
||||
// Add lowercased paths (for ASCII case-insensitive matching via NOCASE)
|
||||
nfc := strings.ToLower(nfcRaw)
|
||||
if _, ok := seen[nfc]; !ok {
|
||||
seen[nfc] = struct{}{}
|
||||
lookupCandidates = append(lookupCandidates, nfc)
|
||||
}
|
||||
nfd := strings.ToLower(nfdRaw)
|
||||
if _, ok := seen[nfd]; !ok {
|
||||
seen[nfd] = struct{}{}
|
||||
lookupCandidates = append(lookupCandidates, nfd)
|
||||
}
|
||||
}
|
||||
|
||||
found, err := mediaFileRepository.FindByPaths(lookupCandidates)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
|
||||
continue
|
||||
}
|
||||
// Build lookup map with library-qualified keys, normalized for comparison
|
||||
|
||||
// Build lookup map with library-qualified keys, normalized for comparison.
|
||||
// Canonicalize to NFC so NFD/NFC become comparable.
|
||||
existing := make(map[string]int, len(found))
|
||||
for idx := range found {
|
||||
// Normalize to lowercase for case-insensitive comparison
|
||||
// Key format: "libraryID:path"
|
||||
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
|
||||
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path)))
|
||||
existing[key] = idx
|
||||
}
|
||||
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
for _, path := range resolvedPaths {
|
||||
idx, ok := existing[path]
|
||||
key := strings.ToLower(norm.NFC.String(path))
|
||||
idx, ok := existing[key]
|
||||
if ok {
|
||||
mfs = append(mfs, found[idx])
|
||||
} else {
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
|
||||
// Prefer logging a composed representation when possible to avoid confusing output
|
||||
// with decomposed combining marks.
|
||||
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,7 +429,20 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -409,7 +462,10 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -473,6 +529,7 @@ type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
@@ -483,5 +540,8 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
|
||||
@@ -112,8 +112,78 @@ var _ = Describe("Playlists", func() {
|
||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||
})
|
||||
It("parses NSP with public: true and creates public playlist", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Public Playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
It("parses NSP with public: false and creates private playlist", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Private Playlist"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
|
||||
func(storedForm, filesystemForm string) {
|
||||
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
|
||||
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
|
||||
plsNameNFD := norm.NFD.String(plsNameNFC)
|
||||
Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ
|
||||
|
||||
nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD}
|
||||
storedName := nameByForm[storedForm]
|
||||
filesystemName := nameByForm[filesystemForm]
|
||||
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||
ps = core.NewPlaylists(ds)
|
||||
|
||||
// Create the playlist file on disk with the filesystem's normalization form
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed())
|
||||
|
||||
// Pre-populate mock repo with the stored normalization form
|
||||
storedPath := tmpDir + "/" + storedName + ".m3u"
|
||||
existingPls := &model.Playlist{
|
||||
ID: "existing-id",
|
||||
Name: "Existing Playlist",
|
||||
Path: storedPath,
|
||||
Sync: true,
|
||||
}
|
||||
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
|
||||
|
||||
// Import using the filesystem's normalization form
|
||||
plsFolder := &model.Folder{
|
||||
ID: "1",
|
||||
LibraryID: 1,
|
||||
LibraryPath: tmpDir,
|
||||
Path: "",
|
||||
Name: "",
|
||||
}
|
||||
pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should update existing playlist, not create new one
|
||||
Expect(pls.ID).To(Equal("existing-id"))
|
||||
Expect(pls.Name).To(Equal("Existing Playlist"))
|
||||
},
|
||||
Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"),
|
||||
Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"),
|
||||
)
|
||||
|
||||
Describe("Cross-library relative paths", func() {
|
||||
var tmpDir, plsDir, songsDir string
|
||||
|
||||
@@ -425,23 +495,79 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||
})
|
||||
|
||||
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
|
||||
// Simulate macOS filesystem: stores paths in NFD (decomposed) form
|
||||
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
|
||||
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
|
||||
repo.data = []string{nfdPath}
|
||||
|
||||
// Simulate Apple Music M3U: uses NFC (composed) form
|
||||
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
|
||||
m3u := nfcPath + "\n"
|
||||
// Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation,
|
||||
// so we need exact matching for non-ASCII characters.
|
||||
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
|
||||
// Fullwidth uppercase ACROSS (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33)
|
||||
repo.data = []string{
|
||||
"plex/02 - ACROSS.flac",
|
||||
}
|
||||
m3u := "/music/plex/02 - ACROSS.flac\n"
|
||||
f := strings.NewReader(m3u)
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
// Should match despite different Unicode normalization forms
|
||||
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("plex/02 - ACROSS.flac"))
|
||||
})
|
||||
|
||||
// Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms
|
||||
// macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form.
|
||||
DescribeTable("matches paths across Unicode NFC/NFD normalization",
|
||||
func(description, pathNFC string, dbForm, playlistForm norm.Form) {
|
||||
pathNFD := norm.NFD.String(pathNFC)
|
||||
Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters")
|
||||
|
||||
// Set up DB with specified normalization form
|
||||
var dbPath string
|
||||
if dbForm == norm.NFC {
|
||||
dbPath = pathNFC
|
||||
} else {
|
||||
dbPath = pathNFD
|
||||
}
|
||||
repo.data = []string{dbPath}
|
||||
|
||||
// Set up playlist with specified normalization form
|
||||
var playlistPath string
|
||||
if playlistForm == norm.NFC {
|
||||
playlistPath = pathNFC
|
||||
} else {
|
||||
playlistPath = pathNFD
|
||||
}
|
||||
m3u := "/music/" + playlistPath + "\n"
|
||||
f := strings.NewReader(m3u)
|
||||
|
||||
pls, err := ps.ImportM3U(ctx, f)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].Path).To(Equal(dbPath))
|
||||
},
|
||||
// French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300)
|
||||
Entry("French diacritics - DB:NFD, playlist:NFC",
|
||||
"macOS DB with Apple Music playlist",
|
||||
"artist/Michèle/song.mp3", norm.NFD, norm.NFC),
|
||||
|
||||
// Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099)
|
||||
Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)",
|
||||
"Linux/Windows DB with NFC playlist",
|
||||
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC),
|
||||
Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)",
|
||||
"macOS DB with NFC playlist",
|
||||
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC),
|
||||
|
||||
// Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306)
|
||||
Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)",
|
||||
"macOS DB with NFC playlist",
|
||||
"Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC),
|
||||
|
||||
// Polish: ó (U+00F3) decomposes to o + combining acute (U+0301)
|
||||
Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)",
|
||||
"macOS DB with NFC playlist",
|
||||
"Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC),
|
||||
Entry("Polish diacritics - DB:NFC, playlist:NFD",
|
||||
"Linux/Windows DB with macOS-exported playlist",
|
||||
"Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
Describe("InPlaylistsPath", func() {
|
||||
@@ -542,9 +668,6 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
var mfs model.MediaFiles
|
||||
|
||||
for idx, dataPath := range r.data {
|
||||
// Normalize the data path to NFD (simulates macOS filesystem storage)
|
||||
normalizedDataPath := norm.NFD.String(dataPath)
|
||||
|
||||
for _, requestPath := range paths {
|
||||
// Strip library qualifier if present (format: "libraryID:path")
|
||||
actualPath := requestPath
|
||||
@@ -556,12 +679,9 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
}
|
||||
}
|
||||
|
||||
// The request path should already be normalized to NFD by production code
|
||||
// before calling FindByPaths (to match DB storage)
|
||||
normalizedRequestPath := norm.NFD.String(actualPath)
|
||||
|
||||
// Case-insensitive comparison (like SQL's "collate nocase")
|
||||
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
|
||||
// Case-insensitive comparison (like SQL's "collate nocase"), but with no
|
||||
// implicit Unicode normalization (SQLite does not normalize NFC/NFD).
|
||||
if strings.EqualFold(actualPath, dataPath) {
|
||||
mfs = append(mfs, model.MediaFile{
|
||||
ID: strconv.Itoa(idx),
|
||||
Path: dataPath, // Return original path from DB
|
||||
@@ -576,10 +696,16 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
|
||||
type mockedPlaylistRepo struct {
|
||||
last *model.Playlist
|
||||
data map[string]*model.Playlist // keyed by path
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
|
||||
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
if r.data != nil {
|
||||
if pls, ok := r.data[path]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
|
||||
23
db/migrations/20260117201522_add_avg_rating_column.sql
Normal file
23
db/migrations/20260117201522_add_avg_rating_column.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
|
||||
|
||||
-- Populate average_rating from existing ratings
|
||||
UPDATE album SET average_rating = coalesce(
|
||||
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
|
||||
0
|
||||
);
|
||||
UPDATE media_file SET average_rating = coalesce(
|
||||
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
|
||||
0
|
||||
);
|
||||
UPDATE artist SET average_rating = coalesce(
|
||||
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
|
||||
0
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE artist DROP COLUMN average_rating;
|
||||
ALTER TABLE media_file DROP COLUMN average_rating;
|
||||
ALTER TABLE album DROP COLUMN average_rating;
|
||||
15
go.mod
15
go.mod
@@ -2,8 +2,13 @@ module github.com/navidrome/navidrome
|
||||
|
||||
go 1.25
|
||||
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
replace (
|
||||
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
@@ -53,13 +58,15 @@ require (
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
github.com/unrolled/secure v1.17.0
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
|
||||
go.senan.xyz/taglib v0.11.1
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/net v0.49.0
|
||||
@@ -91,7 +98,7 @@ require (
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
|
||||
16
go.sum
16
go.sum
@@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
@@ -54,6 +56,8 @@ github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE
|
||||
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
|
||||
@@ -106,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 h1:oKBqR+eQXiIM7X8K1JEg9aoTEePLq/c6Awe484abOuA=
|
||||
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -234,13 +238,15 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
|
||||
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
@@ -269,7 +275,6 @@ github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRci
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
@@ -358,7 +363,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -3,12 +3,13 @@ package model
|
||||
import "time"
|
||||
|
||||
type Annotations struct {
|
||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||
AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
|
||||
@@ -353,11 +353,13 @@ type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
|
||||
type MediaFileRepository interface {
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
@@ -26,10 +27,14 @@ type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibI
|
||||
|
||||
func createGetPID(hash hashFunc) getPIDFunc {
|
||||
var getPID getPIDFunc
|
||||
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
|
||||
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
|
||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||
switch attr {
|
||||
case "albumid":
|
||||
if spec == conf.Server.PID.Album {
|
||||
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
||||
return ""
|
||||
}
|
||||
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
|
||||
case "folder":
|
||||
return filepath.Dir(mf.Path)
|
||||
@@ -49,7 +54,7 @@ func createGetPID(hash hashFunc) getPIDFunc {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
v := getAttr(mf, md, attr, prependLibId)
|
||||
v := getAttr(mf, md, attr, prependLibId, spec)
|
||||
if v != "" {
|
||||
hasValue = true
|
||||
}
|
||||
|
||||
@@ -114,6 +114,24 @@ var _ = Describe("getPID", func() {
|
||||
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
|
||||
})
|
||||
})
|
||||
|
||||
When("albumid configuration refers to albumid recursively", func() {
|
||||
It("should avoid infinite recursion", func() {
|
||||
// Reproduce the issue from #4920
|
||||
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
|
||||
spec := conf.Server.PID.Album
|
||||
md.tags = map[model.TagName][]string{
|
||||
"album": {"Album Name"},
|
||||
"albumversion": {"Version"},
|
||||
"releasedate": {"2022"},
|
||||
}
|
||||
// Should not panic and return a valid PID ignoring the recursive "albumid"
|
||||
Expect(func() {
|
||||
pid := getPID(mf, md, spec, false)
|
||||
Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
|
||||
}).To(Not(Panic()))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("edge cases", func() {
|
||||
|
||||
@@ -119,8 +119,8 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
||||
"artist_id": artistFilter,
|
||||
"year": yearFilter,
|
||||
"recently_played": recentlyPlayedFilter,
|
||||
"starred": booleanFilter,
|
||||
"has_rating": hasRatingFilter,
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"has_rating": annotationBoolFilter("rating"),
|
||||
"missing": booleanFilter,
|
||||
"genre_id": tagIDFilter,
|
||||
"role_total_id": allRolesFilter,
|
||||
@@ -149,10 +149,6 @@ func recentlyPlayedFilter(string, interface{}) Sqlizer {
|
||||
return Gt{"play_count": 0}
|
||||
}
|
||||
|
||||
func hasRatingFilter(string, interface{}) Sqlizer {
|
||||
return Gt{"rating": 0}
|
||||
}
|
||||
|
||||
func yearFilter(_ string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
And{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -77,6 +78,82 @@ var _ = Describe("AlbumRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Filters", func() {
|
||||
var albumWithoutAnnotation model.Album
|
||||
|
||||
BeforeEach(func() {
|
||||
// Create album without any annotation (no star, no rating)
|
||||
albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
|
||||
Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
|
||||
})
|
||||
|
||||
Describe("starred", func() {
|
||||
It("false includes items without annotations", func() {
|
||||
res, err := albumRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "false"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
albums := res.(model.Albums)
|
||||
|
||||
var found bool
|
||||
for _, a := range albums {
|
||||
if a.ID == albumWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Album without annotation should be included in starred=false filter")
|
||||
})
|
||||
|
||||
It("true excludes items without annotations", func() {
|
||||
res, err := albumRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "true"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
albums := res.(model.Albums)
|
||||
|
||||
for _, a := range albums {
|
||||
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("has_rating", func() {
|
||||
It("false includes items without annotations", func() {
|
||||
res, err := albumRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"has_rating": "false"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
albums := res.(model.Albums)
|
||||
|
||||
var found bool
|
||||
for _, a := range albums {
|
||||
if a.ID == albumWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Album without annotation should be included in has_rating=false filter")
|
||||
})
|
||||
|
||||
It("true excludes items without annotations", func() {
|
||||
res, err := albumRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"has_rating": "true"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
albums := res.(model.Albums)
|
||||
|
||||
for _, a := range albums {
|
||||
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album.PlayCount", func() {
|
||||
// Implementation is in withAnnotation() method
|
||||
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
|
||||
@@ -126,6 +203,89 @@ var _ = Describe("AlbumRepository", func() {
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Album.AverageRating", func() {
|
||||
It("returns 0 when no ratings exist", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed())
|
||||
|
||||
album, err := albumRepo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.AverageRating).To(Equal(0.0))
|
||||
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("returns the user's rating as average when only one user rated", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed())
|
||||
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
|
||||
|
||||
album, err := albumRepo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.AverageRating).To(Equal(4.0))
|
||||
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("calculates average across multiple users", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed())
|
||||
|
||||
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
|
||||
|
||||
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
|
||||
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
|
||||
|
||||
album, err := albumRepo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.AverageRating).To(Equal(4.5))
|
||||
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("excludes zero ratings from average calculation", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed())
|
||||
Expect(albumRepo.SetRating(3, newID)).To(Succeed())
|
||||
|
||||
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
|
||||
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
|
||||
|
||||
album, err := albumRepo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.AverageRating).To(Equal(3.0))
|
||||
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("rounds to 2 decimal places", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed())
|
||||
|
||||
Expect(albumRepo.SetRating(5, newID)).To(Succeed())
|
||||
|
||||
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
|
||||
Expect(user2Repo.SetRating(4, newID)).To(Succeed())
|
||||
|
||||
user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser)
|
||||
user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository)
|
||||
Expect(user3Repo.SetRating(4, newID)).To(Succeed())
|
||||
|
||||
album, err := albumRepo.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333...
|
||||
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("dbAlbum mapping", func() {
|
||||
var (
|
||||
a model.Album
|
||||
|
||||
@@ -133,7 +133,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
r.registerModel(&model.Artist{}, map[string]filterFunc{
|
||||
"id": idFilter(r.tableName),
|
||||
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
|
||||
"starred": booleanFilter,
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"role": roleFilter,
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -386,6 +387,54 @@ var _ = Describe("ArtistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Filters", func() {
|
||||
var artistWithoutAnnotation model.Artist
|
||||
|
||||
BeforeEach(func() {
|
||||
// Create artist without any annotation
|
||||
artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"}
|
||||
err := createArtistWithLibrary(repo, &artistWithoutAnnotation, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if raw, ok := repo.(*artistRepository); ok {
|
||||
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
|
||||
}
|
||||
})
|
||||
|
||||
Describe("starred", func() {
|
||||
It("false includes items without annotations", func() {
|
||||
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "false"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
artists := res.(model.Artists)
|
||||
|
||||
var found bool
|
||||
for _, a := range artists {
|
||||
if a.ID == artistWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter")
|
||||
})
|
||||
|
||||
It("true excludes items without annotations", func() {
|
||||
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "true"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
artists := res.(model.Artists)
|
||||
|
||||
for _, a := range artists {
|
||||
Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MBID and Text Search", func() {
|
||||
var lib2 model.Library
|
||||
var lr model.LibraryRepository
|
||||
|
||||
@@ -95,7 +95,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
|
||||
filters := map[string]filterFunc{
|
||||
"id": idFilter("media_file"),
|
||||
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
|
||||
"starred": booleanFilter,
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
"genre_id": tagIDFilter,
|
||||
"missing": booleanFilter,
|
||||
"artists_id": artistFilter,
|
||||
@@ -124,6 +124,25 @@ func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, er
|
||||
return r.count(query, options...)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[string]int64, error) {
|
||||
sel := r.newSelect(options...).
|
||||
Columns("lower(suffix) as suffix", "count(*) as count").
|
||||
GroupBy("lower(suffix)")
|
||||
var res []struct {
|
||||
Suffix string
|
||||
Count int64
|
||||
}
|
||||
err := r.queryAll(sel, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[string]int64, len(res))
|
||||
for _, c := range res {
|
||||
counts[c.Suffix] = c.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Eq{"media_file.id": id})
|
||||
}
|
||||
@@ -176,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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@@ -41,6 +42,44 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(mr.CountAll()).To(Equal(int64(10)))
|
||||
})
|
||||
|
||||
Describe("CountBySuffix", func() {
|
||||
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"}
|
||||
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"}
|
||||
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"}
|
||||
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"}
|
||||
|
||||
Expect(mr.Put(&mp3File)).To(Succeed())
|
||||
Expect(mr.Put(&flacFile1)).To(Succeed())
|
||||
Expect(mr.Put(&flacFile2)).To(Succeed())
|
||||
Expect(mr.Put(&flacUpperFile)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_ = mr.Delete(mp3File.ID)
|
||||
_ = mr.Delete(flacFile1.ID)
|
||||
_ = mr.Delete(flacFile2.ID)
|
||||
_ = mr.Delete(flacUpperFile.ID)
|
||||
})
|
||||
|
||||
It("counts media files grouped by suffix with lowercase normalization", func() {
|
||||
counts, err := mr.CountBySuffix()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Should have lowercase keys only
|
||||
Expect(counts).To(HaveKey("mp3"))
|
||||
Expect(counts).To(HaveKey("flac"))
|
||||
Expect(counts).ToNot(HaveKey("FLAC"))
|
||||
|
||||
// mp3: 1 file
|
||||
Expect(counts["mp3"]).To(Equal(int64(1)))
|
||||
// flac: 3 files (2 lowercase + 1 uppercase normalized)
|
||||
Expect(counts["flac"]).To(Equal(int64(3)))
|
||||
})
|
||||
})
|
||||
|
||||
It("returns songs ordered by lyrics with a specific title/artist", func() {
|
||||
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
|
||||
results, err := mr.GetAll(model.QueryOptions{
|
||||
@@ -119,6 +158,74 @@ var _ = Describe("MediaRepository", func() {
|
||||
Expect(mf.PlayCount).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
Describe("AverageRating", func() {
|
||||
var raw *mediaFileRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
raw = mr.(*mediaFileRepository)
|
||||
})
|
||||
|
||||
It("returns 0 when no ratings exist", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed())
|
||||
|
||||
mf, err := mr.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.AverageRating).To(Equal(0.0))
|
||||
|
||||
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("returns the user's rating as average when only one user rated", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed())
|
||||
Expect(mr.SetRating(5, newID)).To(Succeed())
|
||||
|
||||
mf, err := mr.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.AverageRating).To(Equal(5.0))
|
||||
|
||||
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("calculates average across multiple users", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed())
|
||||
|
||||
Expect(mr.SetRating(3, newID)).To(Succeed())
|
||||
|
||||
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
|
||||
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
|
||||
|
||||
mf, err := mr.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.AverageRating).To(Equal(4.0))
|
||||
|
||||
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
|
||||
It("excludes zero ratings from average calculation", func() {
|
||||
newID := id.NewRandom()
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed())
|
||||
|
||||
Expect(mr.SetRating(4, newID)).To(Succeed())
|
||||
|
||||
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
|
||||
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
|
||||
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
|
||||
|
||||
mf, err := mr.Get(newID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mf.AverageRating).To(Equal(4.0))
|
||||
|
||||
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
|
||||
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
|
||||
})
|
||||
})
|
||||
|
||||
It("preserves play date if and only if provided date is older", func() {
|
||||
id := "incplay.playdate"
|
||||
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
|
||||
@@ -311,6 +418,50 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Filters", func() {
|
||||
var mfWithoutAnnotation model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"}
|
||||
Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_ = mr.Delete(mfWithoutAnnotation.ID)
|
||||
})
|
||||
|
||||
Describe("starred", func() {
|
||||
It("false includes items without annotations", func() {
|
||||
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "false"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := res.(model.MediaFiles)
|
||||
|
||||
var found bool
|
||||
for _, f := range files {
|
||||
if f.ID == mfWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
|
||||
})
|
||||
|
||||
It("true excludes items without annotations", func() {
|
||||
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": "true"},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
files := res.(model.MediaFiles)
|
||||
|
||||
for _, f := range files {
|
||||
Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Search", func() {
|
||||
Context("text search", func() {
|
||||
It("finds media files by title", func() {
|
||||
@@ -410,4 +561,92 @@ var _ = Describe("MediaRepository", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("FindByPaths", func() {
|
||||
// Test fixtures for Unicode and case-sensitivity tests
|
||||
var testFiles []model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
testFiles = []model.MediaFile{
|
||||
{ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
|
||||
{ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
|
||||
// Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
|
||||
{ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"},
|
||||
// French diacritic: è (U+00E8, can decompose to e + combining grave)
|
||||
{ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"},
|
||||
}
|
||||
for _, mf := range testFiles {
|
||||
Expect(mr.Put(&mf)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
for _, mf := range testFiles {
|
||||
_ = mr.Delete(mf.ID)
|
||||
}
|
||||
})
|
||||
|
||||
It("finds files by exact path", func() {
|
||||
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].ID).To(Equal("findpath-1"))
|
||||
})
|
||||
|
||||
It("finds files case-insensitively for ASCII characters (NOCASE)", func() {
|
||||
// SQLite's COLLATE NOCASE handles ASCII case-insensitivity
|
||||
results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].ID).To(Equal("findpath-1"))
|
||||
})
|
||||
|
||||
It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() {
|
||||
// SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence
|
||||
// The DB has fullwidth uppercase ACROSS, searching with exact match should work
|
||||
results, err := mr.FindByPaths([]string{"1:plex/02 - ACROSS.flac"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].ID).To(Equal("findpath-3"))
|
||||
|
||||
// Searching with fullwidth lowercase across should NOT match
|
||||
// (this is the SQLite limitation that requires exact matching for non-ASCII)
|
||||
results, err = mr.FindByPaths([]string{"1:plex/02 - across.flac"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns multiple files when querying multiple paths", func() {
|
||||
results, err := mr.FindByPaths([]string{
|
||||
"1:artist/Album/track.mp3",
|
||||
"1:artist/Album/UPPER.mp3",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns empty slice for non-existent paths", func() {
|
||||
results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns empty slice for empty input", func() {
|
||||
results, err := mr.FindByPaths([]string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("handles library-qualified paths correctly", func() {
|
||||
// Library 1 should find the file
|
||||
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(HaveLen(1))
|
||||
|
||||
// Library 2 should NOT find it (file is in library 1)
|
||||
results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(results).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,7 +130,8 @@ var (
|
||||
var (
|
||||
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
||||
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
|
||||
testUsers = model.Users{adminUser, regularUser}
|
||||
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
|
||||
testUsers = model.Users{adminUser, regularUser, thirdUser}
|
||||
)
|
||||
|
||||
func p(path string) string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
@@ -17,7 +18,7 @@ const annotationTable = "annotation"
|
||||
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
if userID == invalidUserId {
|
||||
return query
|
||||
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||
}
|
||||
query = query.
|
||||
LeftJoin("annotation on ("+
|
||||
@@ -38,9 +39,24 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
|
||||
query = query.Columns("coalesce(play_count, 0) as play_count")
|
||||
}
|
||||
|
||||
query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
func annotationBoolFilter(field string) func(string, any) Sqlizer {
|
||||
return func(_ string, value any) Sqlizer {
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if strings.ToLower(v) == "true" {
|
||||
return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field))
|
||||
}
|
||||
return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field))
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) annId(itemID ...string) And {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
return And{
|
||||
@@ -79,7 +95,22 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
ratedAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.updateAvgRating(itemID)
|
||||
}
|
||||
|
||||
func (r sqlRepository) updateAvgRating(itemID string) error {
|
||||
upd := Update(r.tableName).
|
||||
Where(Eq{"id": itemID}).
|
||||
Set("average_rating", Expr(
|
||||
"coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)",
|
||||
itemID, r.tableName,
|
||||
))
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
|
||||
153
persistence/sql_annotations_test.go
Normal file
153
persistence/sql_annotations_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Annotation Filters", func() {
|
||||
var (
|
||||
albumRepo *albumRepository
|
||||
albumWithoutAnnotation model.Album
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"})
|
||||
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
|
||||
|
||||
// Create album without any annotation (no star, no rating)
|
||||
albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
|
||||
Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
|
||||
})
|
||||
|
||||
Describe("annotationBoolFilter", func() {
|
||||
DescribeTable("creates correct SQL expressions",
|
||||
func(field, value string, expectedSQL string, expectedArgs []interface{}) {
|
||||
sqlizer := annotationBoolFilter(field)(field, value)
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal(expectedArgs))
|
||||
},
|
||||
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)),
|
||||
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
|
||||
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
|
||||
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)),
|
||||
)
|
||||
|
||||
It("returns nil if value is not a string", func() {
|
||||
sqlizer := annotationBoolFilter("starred")("starred", 123)
|
||||
Expect(sqlizer).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("starredFilter", func() {
|
||||
It("false includes items without annotations", func() {
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: annotationBoolFilter("starred")("starred", "false"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var found bool
|
||||
for _, a := range albums {
|
||||
if a.ID == albumWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
|
||||
})
|
||||
|
||||
It("true excludes items without annotations", func() {
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: annotationBoolFilter("starred")("starred", "true"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for _, a := range albums {
|
||||
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Describe("hasRatingFilter", func() {
|
||||
It("false includes items without annotations", func() {
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: annotationBoolFilter("rating")("rating", "false"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var found bool
|
||||
for _, a := range albums {
|
||||
if a.ID == albumWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
|
||||
})
|
||||
|
||||
It("true excludes items without annotations", func() {
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: annotationBoolFilter("rating")("rating", "true"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
for _, a := range albums {
|
||||
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
||||
}
|
||||
})
|
||||
|
||||
It("true includes items with rating > 0", func() {
|
||||
// Create album with rating 1
|
||||
ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1}
|
||||
Expect(albumRepo.Put(&ratedAlbum)).To(Succeed())
|
||||
Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed())
|
||||
defer func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID}))
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID}))
|
||||
}()
|
||||
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Filters: annotationBoolFilter("rating")("rating", "true"),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var found bool
|
||||
for _, a := range albums {
|
||||
if a.ID == ratedAlbum.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
|
||||
})
|
||||
})
|
||||
|
||||
It("ignores invalid filter values (not strings)", func() {
|
||||
res, err := albumRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"starred": 123},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
albums := res.(model.Albums)
|
||||
|
||||
var found bool
|
||||
for _, a := range albums {
|
||||
if a.ID == albumWithoutAnnotation.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored")
|
||||
})
|
||||
})
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -40,6 +40,18 @@ type MetadataAgent interface {
|
||||
// GetAlbumImages retrieves images for an album.
|
||||
//nd:export name=nd_get_album_images
|
||||
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
|
||||
|
||||
// GetSimilarSongsByTrack retrieves songs similar to a specific track.
|
||||
//nd:export name=nd_get_similar_songs_by_track
|
||||
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
|
||||
|
||||
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
|
||||
//nd:export name=nd_get_similar_songs_by_album
|
||||
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
|
||||
|
||||
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
|
||||
//nd:export name=nd_get_similar_songs_by_artist
|
||||
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
|
||||
}
|
||||
|
||||
// ArtistMBIDRequest is the request for GetArtistMBID.
|
||||
@@ -122,7 +134,7 @@ type TopSongsRequest struct {
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SongRef is a reference to a song with name and optional MBID.
|
||||
// SongRef is a reference to a song with metadata for matching.
|
||||
type SongRef struct {
|
||||
// ID is the internal Navidrome mediafile ID (if known).
|
||||
ID string `json:"id,omitempty"`
|
||||
@@ -130,6 +142,18 @@ 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.
|
||||
ArtistMBID string `json:"artistMbid,omitempty"`
|
||||
// Album is the album name.
|
||||
Album string `json:"album,omitempty"`
|
||||
// AlbumMBID is the MusicBrainz release ID.
|
||||
AlbumMBID string `json:"albumMbid,omitempty"`
|
||||
// Duration is the song duration in seconds.
|
||||
Duration float32 `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// TopSongsResponse is the response for GetArtistTopSongs.
|
||||
@@ -165,3 +189,49 @@ type AlbumImagesResponse struct {
|
||||
// Images is the list of album images.
|
||||
Images []ImageInfo `json:"images"`
|
||||
}
|
||||
|
||||
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||
type SimilarSongsByTrackRequest struct {
|
||||
// ID is the internal Navidrome mediafile ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the track title.
|
||||
Name string `json:"name"`
|
||||
// Artist is the artist name.
|
||||
Artist string `json:"artist"`
|
||||
// MBID is the MusicBrainz recording ID (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// Count is the maximum number of similar songs to return.
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||
type SimilarSongsByAlbumRequest struct {
|
||||
// ID is the internal Navidrome album ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the album name.
|
||||
Name string `json:"name"`
|
||||
// Artist is the album artist name.
|
||||
Artist string `json:"artist"`
|
||||
// MBID is the MusicBrainz release ID (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// Count is the maximum number of similar songs to return.
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||
type SimilarSongsByArtistRequest struct {
|
||||
// ID is the internal Navidrome artist ID.
|
||||
ID string `json:"id"`
|
||||
// Name is the artist name.
|
||||
Name string `json:"name"`
|
||||
// MBID is the MusicBrainz artist ID (if known).
|
||||
MBID string `json:"mbid,omitempty"`
|
||||
// Count is the maximum number of similar songs to return.
|
||||
Count int32 `json:"count"`
|
||||
}
|
||||
|
||||
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||
type SimilarSongsResponse struct {
|
||||
// Songs is the list of similar songs.
|
||||
Songs []SongRef `json:"songs"`
|
||||
}
|
||||
|
||||
@@ -64,6 +64,30 @@ exports:
|
||||
output:
|
||||
$ref: '#/components/schemas/AlbumImagesResponse'
|
||||
contentType: application/json
|
||||
nd_get_similar_songs_by_track:
|
||||
description: GetSimilarSongsByTrack retrieves songs similar to a specific track.
|
||||
input:
|
||||
$ref: '#/components/schemas/SimilarSongsByTrackRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/SimilarSongsResponse'
|
||||
contentType: application/json
|
||||
nd_get_similar_songs_by_album:
|
||||
description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
|
||||
input:
|
||||
$ref: '#/components/schemas/SimilarSongsByAlbumRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/SimilarSongsResponse'
|
||||
contentType: application/json
|
||||
nd_get_similar_songs_by_artist:
|
||||
description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
|
||||
input:
|
||||
$ref: '#/components/schemas/SimilarSongsByArtistRequest'
|
||||
contentType: application/json
|
||||
output:
|
||||
$ref: '#/components/schemas/SimilarSongsResponse'
|
||||
contentType: application/json
|
||||
components:
|
||||
schemas:
|
||||
AlbumImagesResponse:
|
||||
@@ -229,8 +253,86 @@ components:
|
||||
$ref: '#/components/schemas/ArtistRef'
|
||||
required:
|
||||
- artists
|
||||
SimilarSongsByAlbumRequest:
|
||||
description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome album ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the album name.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the album artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz release ID (if known).
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Count is the maximum number of similar songs to return.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- artist
|
||||
- count
|
||||
SimilarSongsByArtistRequest:
|
||||
description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome artist ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz artist ID (if known).
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Count is the maximum number of similar songs to return.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- count
|
||||
SimilarSongsByTrackRequest:
|
||||
description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: ID is the internal Navidrome mediafile ID.
|
||||
name:
|
||||
type: string
|
||||
description: Name is the track title.
|
||||
artist:
|
||||
type: string
|
||||
description: Artist is the artist name.
|
||||
mbid:
|
||||
type: string
|
||||
description: MBID is the MusicBrainz recording ID (if known).
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
description: Count is the maximum number of similar songs to return.
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- artist
|
||||
- count
|
||||
SimilarSongsResponse:
|
||||
description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
|
||||
properties:
|
||||
songs:
|
||||
type: array
|
||||
description: Songs is the list of similar songs.
|
||||
items:
|
||||
$ref: '#/components/schemas/SongRef'
|
||||
required:
|
||||
- songs
|
||||
SongRef:
|
||||
description: SongRef is a reference to a song with name and optional MBID.
|
||||
description: SongRef is a reference to a song with metadata for matching.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -241,6 +343,25 @@ 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.
|
||||
artistMbid:
|
||||
type: string
|
||||
description: ArtistMBID is the MusicBrainz artist ID.
|
||||
album:
|
||||
type: string
|
||||
description: Album is the album name.
|
||||
albumMbid:
|
||||
type: string
|
||||
description: AlbumMBID is the MusicBrainz release ID.
|
||||
duration:
|
||||
type: number
|
||||
format: float
|
||||
description: Duration is the song duration in seconds.
|
||||
required:
|
||||
- name
|
||||
TopSongsRequest:
|
||||
|
||||
@@ -4,10 +4,10 @@ go 1.25
|
||||
|
||||
require (
|
||||
github.com/extism/go-pdk v1.1.3
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.3
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
golang.org/x/tools v0.40.0
|
||||
github.com/onsi/ginkgo/v2 v2.27.5
|
||||
github.com/onsi/gomega v1.39.0
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
|
||||
golang.org/x/tools v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -16,13 +16,11 @@ require (
|
||||
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/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.31.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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=
|
||||
@@ -19,8 +20,8 @@ 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/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
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=
|
||||
@@ -31,16 +32,16 @@ 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/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
@@ -51,26 +52,20 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
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=
|
||||
|
||||
@@ -568,6 +568,18 @@ func skipSerializingFunc(goType string) string {
|
||||
return "String::is_empty"
|
||||
case "bool":
|
||||
return "std::ops::Not::not"
|
||||
case "int32":
|
||||
return "is_zero_i32"
|
||||
case "uint32":
|
||||
return "is_zero_u32"
|
||||
case "int64":
|
||||
return "is_zero_i64"
|
||||
case "uint64":
|
||||
return "is_zero_u64"
|
||||
case "float32":
|
||||
return "is_zero_f32"
|
||||
case "float64":
|
||||
return "is_zero_f64"
|
||||
default:
|
||||
return "Option::is_none"
|
||||
}
|
||||
|
||||
@@ -1234,6 +1234,37 @@ type OnInitOutput struct {
|
||||
})
|
||||
|
||||
var _ = Describe("Rust Generation", func() {
|
||||
Describe("skipSerializingFunc", func() {
|
||||
It("should return Option::is_none for pointer, slice, and map types", func() {
|
||||
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
|
||||
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
|
||||
})
|
||||
|
||||
It("should return String::is_empty for string type", func() {
|
||||
Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
|
||||
})
|
||||
|
||||
It("should return std::ops::Not::not for bool type", func() {
|
||||
Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
|
||||
})
|
||||
|
||||
It("should return is_zero_* functions for numeric types", func() {
|
||||
Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
|
||||
Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
|
||||
Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
|
||||
Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
|
||||
Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
|
||||
Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
|
||||
})
|
||||
|
||||
It("should return Option::is_none for unknown types", func() {
|
||||
Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("rustOutputType", func() {
|
||||
It("should convert Go primitives to Rust primitives", func() {
|
||||
Expect(rustOutputType("bool")).To(Equal("bool"))
|
||||
|
||||
@@ -7,6 +7,20 @@ use serde::{Deserialize, Serialize};
|
||||
{{- if hasHashMap .Capability}}
|
||||
use std::collections::HashMap;
|
||||
{{- end}}
|
||||
|
||||
// Helper functions for skip_serializing_if with numeric types
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
|
||||
#[allow(dead_code)]
|
||||
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate type alias definitions */ -}}
|
||||
|
||||
@@ -466,9 +466,7 @@ func RustDefaultValue(goType string) string {
|
||||
switch goType {
|
||||
case "string":
|
||||
return `String::new()`
|
||||
case "int", "int32":
|
||||
return "0"
|
||||
case "int64":
|
||||
case "int", "int32", "int64", "uint", "uint32", "uint64":
|
||||
return "0"
|
||||
case "float32", "float64":
|
||||
return "0.0"
|
||||
@@ -602,6 +600,10 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
|
||||
return "i32"
|
||||
case "int64":
|
||||
return "i64"
|
||||
case "uint", "uint32":
|
||||
return "u32"
|
||||
case "uint64":
|
||||
return "u64"
|
||||
case "float32":
|
||||
return "f32"
|
||||
case "float64":
|
||||
|
||||
@@ -106,7 +106,7 @@ func buildExport(export Export) xtpExport {
|
||||
// isPrimitiveGoType returns true if the Go type is a primitive type.
|
||||
func isPrimitiveGoType(goType string) bool {
|
||||
switch goType {
|
||||
case "bool", "string", "int", "int32", "int64", "float32", "float64", "[]byte":
|
||||
case "bool", "string", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "[]byte":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -302,6 +302,12 @@ func goTypeToXTPTypeAndFormat(goType string) (typ, format string) {
|
||||
return "integer", "int32"
|
||||
case "int64":
|
||||
return "integer", "int64"
|
||||
case "uint", "uint32":
|
||||
// XTP schema doesn't support unsigned formats; use int64 to hold full uint32 range
|
||||
return "integer", "int64"
|
||||
case "uint64":
|
||||
// XTP schema doesn't support unsigned formats; use int64 (may lose precision for large values)
|
||||
return "integer", "int64"
|
||||
case "float32":
|
||||
return "number", "float"
|
||||
case "float64":
|
||||
|
||||
@@ -3,10 +3,11 @@ package internal
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/xeipuuv/gojsonschema"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -25,27 +26,61 @@ func ValidateXTPSchema(generatedSchema []byte) error {
|
||||
return fmt.Errorf("failed to parse generated schema as YAML: %w", err)
|
||||
}
|
||||
|
||||
// Convert to JSON for the validator
|
||||
jsonBytes, err := json.Marshal(schemaDoc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert schema to JSON: %w", err)
|
||||
// Parse the XTP schema JSON
|
||||
var xtpSchema any
|
||||
if err := json.Unmarshal([]byte(xtpSchemaJSON), &xtpSchema); err != nil {
|
||||
return fmt.Errorf("failed to parse XTP schema: %w", err)
|
||||
}
|
||||
|
||||
schemaLoader := gojsonschema.NewStringLoader(xtpSchemaJSON)
|
||||
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
|
||||
|
||||
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schema validation failed: %w", err)
|
||||
// Compile the XTP schema
|
||||
compiler := jsonschema.NewCompiler()
|
||||
if err := compiler.AddResource("xtp-schema.json", xtpSchema); err != nil {
|
||||
return fmt.Errorf("failed to add XTP schema resource: %w", err)
|
||||
}
|
||||
|
||||
if !result.Valid() {
|
||||
var errs []string
|
||||
for _, desc := range result.Errors() {
|
||||
errs = append(errs, fmt.Sprintf("- %s", desc))
|
||||
}
|
||||
return fmt.Errorf("schema validation errors:\n%s", strings.Join(errs, "\n"))
|
||||
schema, err := compiler.Compile("xtp-schema.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile XTP schema: %w", err)
|
||||
}
|
||||
|
||||
// Validate the generated schema against XTP schema
|
||||
if err := schema.Validate(schemaDoc); err != nil {
|
||||
return fmt.Errorf("schema validation errors:\n%s", formatValidationErrors(err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatValidationErrors formats jsonschema validation errors into readable strings.
|
||||
func formatValidationErrors(err error) string {
|
||||
var validationErr *jsonschema.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return fmt.Sprintf("- %s", err.Error())
|
||||
}
|
||||
|
||||
var errs []string
|
||||
collectValidationErrors(validationErr, &errs)
|
||||
|
||||
if len(errs) == 0 {
|
||||
return fmt.Sprintf("- %s", validationErr.Error())
|
||||
}
|
||||
return strings.Join(errs, "\n")
|
||||
}
|
||||
|
||||
// collectValidationErrors recursively collects leaf validation errors.
|
||||
func collectValidationErrors(err *jsonschema.ValidationError, errs *[]string) {
|
||||
if len(err.Causes) > 0 {
|
||||
for _, cause := range err.Causes {
|
||||
collectValidationErrors(cause, errs)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Leaf error - format with location if available
|
||||
msg := err.Error()
|
||||
if len(err.InstanceLocation) > 0 {
|
||||
location := strings.Join(err.InstanceLocation, "/")
|
||||
msg = fmt.Sprintf("%s: %s", location, msg)
|
||||
}
|
||||
*errs = append(*errs, fmt.Sprintf("- %s", msg))
|
||||
}
|
||||
|
||||
129
plugins/config_validation.go
Normal file
129
plugins/config_validation.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
// ConfigValidationError represents a validation error with field path and message.
|
||||
type ConfigValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ConfigValidationErrors is a collection of validation errors.
|
||||
type ConfigValidationErrors struct {
|
||||
Errors []ConfigValidationError `json:"errors"`
|
||||
}
|
||||
|
||||
func (e *ConfigValidationErrors) Error() string {
|
||||
if len(e.Errors) == 0 {
|
||||
return "validation failed"
|
||||
}
|
||||
var msgs []string
|
||||
for _, err := range e.Errors {
|
||||
if err.Field != "" {
|
||||
msgs = append(msgs, fmt.Sprintf("%s: %s", err.Field, err.Message))
|
||||
} else {
|
||||
msgs = append(msgs, err.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// ValidateConfig validates a config JSON string against a plugin's config schema.
|
||||
// If the manifest has no config schema, it returns an error indicating the plugin
|
||||
// has no configurable options.
|
||||
// Returns nil if validation passes, ConfigValidationErrors if validation fails.
|
||||
func ValidateConfig(manifest *Manifest, configJSON string) error {
|
||||
// If no config schema defined, plugin has no configurable options
|
||||
if !manifest.HasConfigSchema() {
|
||||
return fmt.Errorf("plugin has no configurable options")
|
||||
}
|
||||
|
||||
// Parse the config JSON (empty string treated as empty object)
|
||||
var configData any
|
||||
if configJSON == "" {
|
||||
configData = map[string]any{}
|
||||
} else {
|
||||
if err := json.Unmarshal([]byte(configJSON), &configData); err != nil {
|
||||
return &ConfigValidationErrors{
|
||||
Errors: []ConfigValidationError{{
|
||||
Message: fmt.Sprintf("invalid JSON: %v", err),
|
||||
}},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile the schema
|
||||
compiler := jsonschema.NewCompiler()
|
||||
if err := compiler.AddResource("schema.json", manifest.Config.Schema); err != nil {
|
||||
return fmt.Errorf("adding schema resource: %w", err)
|
||||
}
|
||||
|
||||
schema, err := compiler.Compile("schema.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling schema: %w", err)
|
||||
}
|
||||
|
||||
// Validate config against schema
|
||||
if err := schema.Validate(configData); err != nil {
|
||||
return convertValidationError(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertValidationError converts jsonschema validation errors to our format.
|
||||
func convertValidationError(err error) *ConfigValidationErrors {
|
||||
var validationErr *jsonschema.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return &ConfigValidationErrors{
|
||||
Errors: []ConfigValidationError{{
|
||||
Message: err.Error(),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
var configErrors []ConfigValidationError
|
||||
collectErrors(validationErr, &configErrors)
|
||||
|
||||
if len(configErrors) == 0 {
|
||||
configErrors = append(configErrors, ConfigValidationError{
|
||||
Message: validationErr.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return &ConfigValidationErrors{Errors: configErrors}
|
||||
}
|
||||
|
||||
// collectErrors recursively collects validation errors from the error tree.
|
||||
func collectErrors(err *jsonschema.ValidationError, errors *[]ConfigValidationError) {
|
||||
// If there are child errors, collect from them
|
||||
if len(err.Causes) > 0 {
|
||||
for _, cause := range err.Causes {
|
||||
collectErrors(cause, errors)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Leaf error - add it
|
||||
field := ""
|
||||
if len(err.InstanceLocation) > 0 {
|
||||
field = strings.Join(err.InstanceLocation, "/")
|
||||
}
|
||||
|
||||
*errors = append(*errors, ConfigValidationError{
|
||||
Field: field,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// HasConfigSchema returns true if the manifest defines a config schema.
|
||||
func (m *Manifest) HasConfigSchema() bool {
|
||||
return m.Config != nil && m.Config.Schema != nil
|
||||
}
|
||||
186
plugins/config_validation_test.go
Normal file
186
plugins/config_validation_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Config Validation", func() {
|
||||
Describe("ValidateConfig", func() {
|
||||
Context("when manifest has no config schema", func() {
|
||||
It("returns an error", func() {
|
||||
manifest := &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
err := ValidateConfig(manifest, `{"key": "value"}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no configurable options"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when manifest has config schema", func() {
|
||||
var manifest *Manifest
|
||||
|
||||
BeforeEach(func() {
|
||||
manifest = &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
Config: &ConfigDefinition{
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"apiKey": map[string]any{
|
||||
"type": "string",
|
||||
"description": "API key for the service",
|
||||
"minLength": float64(1),
|
||||
},
|
||||
"timeout": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": float64(1),
|
||||
"maximum": float64(300),
|
||||
},
|
||||
"enabled": map[string]any{
|
||||
"type": "boolean",
|
||||
},
|
||||
},
|
||||
"required": []any{"apiKey"},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("accepts valid config", func() {
|
||||
err := ValidateConfig(manifest, `{"apiKey": "secret123", "timeout": 30}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects empty config when required fields are missing", func() {
|
||||
err := ValidateConfig(manifest, "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("apiKey"))
|
||||
|
||||
err = ValidateConfig(manifest, "{}")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("apiKey"))
|
||||
})
|
||||
|
||||
It("rejects config missing required field", func() {
|
||||
err := ValidateConfig(manifest, `{"timeout": 30}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("apiKey"))
|
||||
})
|
||||
|
||||
It("rejects config with wrong type", func() {
|
||||
err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": "not a number"}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("timeout"))
|
||||
})
|
||||
|
||||
It("rejects config with value out of range", func() {
|
||||
err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": 500}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("timeout"))
|
||||
})
|
||||
|
||||
It("rejects config with empty required string", func() {
|
||||
err := ValidateConfig(manifest, `{"apiKey": ""}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("apiKey"))
|
||||
})
|
||||
|
||||
It("rejects invalid JSON", func() {
|
||||
err := ValidateConfig(manifest, `{invalid json}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
var validationErr *ConfigValidationErrors
|
||||
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||
Expect(validationErr.Errors[0].Message).To(ContainSubstring("invalid JSON"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with enum values", func() {
|
||||
It("accepts valid enum value", func() {
|
||||
manifest := &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
Config: &ConfigDefinition{
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"logLevel": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"debug", "info", "warn", "error"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := ValidateConfig(manifest, `{"logLevel": "info"}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("rejects invalid enum value", func() {
|
||||
manifest := &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
Config: &ConfigDefinition{
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"logLevel": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []any{"debug", "info", "warn", "error"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := ValidateConfig(manifest, `{"logLevel": "verbose"}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("HasConfigSchema", func() {
|
||||
It("returns false when config is nil", func() {
|
||||
manifest := &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
}
|
||||
Expect(manifest.HasConfigSchema()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false when schema is nil", func() {
|
||||
manifest := &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
Config: &ConfigDefinition{},
|
||||
}
|
||||
Expect(manifest.HasConfigSchema()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true when schema is present", func() {
|
||||
manifest := &Manifest{
|
||||
Name: "test",
|
||||
Author: "test",
|
||||
Version: "1.0.0",
|
||||
Config: &ConfigDefinition{
|
||||
Schema: map[string]any{
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
}
|
||||
Expect(manifest.HasConfigSchema()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,14 @@ const (
|
||||
|
||||
// ID for the reconnection schedule
|
||||
reconnectScheduleID = "crypto-ticker-reconnect"
|
||||
|
||||
// Config keys (must match manifest.json schema property names)
|
||||
symbolsKey = "symbols"
|
||||
reconnectDelayKey = "reconnectDelay"
|
||||
logPricesKey = "logPrices"
|
||||
|
||||
// Default values
|
||||
defaultReconnectDelay = 5
|
||||
)
|
||||
|
||||
// CoinbaseSubscription message structure
|
||||
@@ -74,36 +82,67 @@ var (
|
||||
func (p *cryptoTickerPlugin) OnInit() error {
|
||||
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
|
||||
|
||||
// Get ticker configuration
|
||||
tickerConfig, ok := pdk.GetConfig("tickers")
|
||||
if !ok || tickerConfig == "" {
|
||||
tickerConfig = "BTC,ETH" // Default tickers
|
||||
}
|
||||
|
||||
tickers := parseTickerSymbols(tickerConfig)
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
|
||||
// Get ticker configuration from JSON schema config
|
||||
symbols := getSymbols()
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols))
|
||||
|
||||
// Connect to WebSocket
|
||||
// Errors won't fail init - reconnect logic will handle it
|
||||
return connectAndSubscribe(tickers)
|
||||
return connectAndSubscribe(symbols)
|
||||
}
|
||||
|
||||
// parseTickerSymbols parses a comma-separated list of ticker symbols
|
||||
func parseTickerSymbols(tickerConfig string) []string {
|
||||
parts := strings.Split(tickerConfig, ",")
|
||||
tickers := make([]string, 0, len(parts))
|
||||
for _, ticker := range parts {
|
||||
ticker = strings.TrimSpace(ticker)
|
||||
if ticker == "" {
|
||||
continue
|
||||
}
|
||||
// Add -USD suffix if not present
|
||||
if !strings.Contains(ticker, "-") {
|
||||
ticker = ticker + "-USD"
|
||||
}
|
||||
tickers = append(tickers, ticker)
|
||||
// getSymbols reads the symbols array from config
|
||||
func getSymbols() []string {
|
||||
defaultSymbols := []string{"BTC-USD"}
|
||||
symbolsJSON, ok := pdk.GetConfig(symbolsKey)
|
||||
if !ok || symbolsJSON == "" {
|
||||
return defaultSymbols
|
||||
}
|
||||
return tickers
|
||||
|
||||
var symbols []string
|
||||
if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil {
|
||||
pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err))
|
||||
return defaultSymbols
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
return defaultSymbols
|
||||
}
|
||||
|
||||
// Normalize symbols - add -USD suffix if not present
|
||||
for i, s := range symbols {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.Contains(s, "-") {
|
||||
symbols[i] = s + "-USD"
|
||||
} else {
|
||||
symbols[i] = s
|
||||
}
|
||||
}
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
// getReconnectDelay reads the reconnect delay from config
|
||||
func getReconnectDelay() int32 {
|
||||
delayStr, ok := pdk.GetConfig(reconnectDelayKey)
|
||||
if !ok || delayStr == "" {
|
||||
return defaultReconnectDelay
|
||||
}
|
||||
|
||||
var delay int
|
||||
if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 {
|
||||
return defaultReconnectDelay
|
||||
}
|
||||
return int32(delay)
|
||||
}
|
||||
|
||||
// shouldLogPrices reads the logPrices setting from config
|
||||
func shouldLogPrices() bool {
|
||||
logStr, ok := pdk.GetConfig(logPricesKey)
|
||||
if !ok || logStr == "" {
|
||||
return false
|
||||
}
|
||||
return logStr == "true"
|
||||
}
|
||||
|
||||
// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers
|
||||
@@ -164,14 +203,16 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
|
||||
// Calculate 24h change percentage
|
||||
change := calculatePercentChange(ticker.Open24h, ticker.Price)
|
||||
|
||||
// Log ticker information
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
|
||||
ticker.ProductID,
|
||||
ticker.Price,
|
||||
change,
|
||||
ticker.BestBid,
|
||||
ticker.BestAsk,
|
||||
))
|
||||
// Log ticker information (only if enabled in config)
|
||||
if shouldLogPrices() {
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
|
||||
ticker.ProductID,
|
||||
ticker.Price,
|
||||
change,
|
||||
ticker.BestBid,
|
||||
ticker.BestAsk,
|
||||
))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -196,10 +237,11 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
|
||||
|
||||
// Only attempt reconnect for our connection
|
||||
if input.ConnectionID == connectionID {
|
||||
pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...")
|
||||
delay := getReconnectDelay()
|
||||
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay))
|
||||
|
||||
// Schedule a one-time reconnection attempt
|
||||
_, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID)
|
||||
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
|
||||
}
|
||||
@@ -218,20 +260,16 @@ func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest
|
||||
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
|
||||
|
||||
// Get ticker configuration
|
||||
tickerConfig, ok := pdk.GetConfig("tickers")
|
||||
if !ok || tickerConfig == "" {
|
||||
tickerConfig = "BTC,ETH"
|
||||
}
|
||||
|
||||
tickers := parseTickerSymbols(tickerConfig)
|
||||
symbols := getSymbols()
|
||||
|
||||
// Try to connect and subscribe
|
||||
err := connectAndSubscribe(tickers)
|
||||
err := connectAndSubscribe(symbols)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err))
|
||||
delay := getReconnectDelay() * 2 // Double delay on failure
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay))
|
||||
|
||||
// Schedule another attempt
|
||||
_, err := host.SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID)
|
||||
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
|
||||
if err != nil {
|
||||
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
|
||||
}
|
||||
|
||||
@@ -4,10 +4,62 @@
|
||||
"version": "1.0.0",
|
||||
"description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
|
||||
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
|
||||
"permissions": {
|
||||
"config": {
|
||||
"reason": "To read ticker symbols configuration"
|
||||
"config": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"symbols": {
|
||||
"type": "array",
|
||||
"title": "Trading Pairs",
|
||||
"description": "Cryptocurrency trading pairs to track (default: BTC-USD)",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"title": "Trading Pair",
|
||||
"pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$",
|
||||
"description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)"
|
||||
},
|
||||
"default": ["BTC-USD"]
|
||||
},
|
||||
"reconnectDelay": {
|
||||
"type": "integer",
|
||||
"title": "Reconnect Delay",
|
||||
"description": "Delay in seconds before attempting to reconnect after connection loss",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 60
|
||||
},
|
||||
"logPrices": {
|
||||
"type": "boolean",
|
||||
"title": "Log Prices",
|
||||
"description": "Whether to log price updates to the server log",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiSchema": {
|
||||
"type": "VerticalLayout",
|
||||
"elements": [
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/symbols"
|
||||
},
|
||||
{
|
||||
"type": "HorizontalLayout",
|
||||
"elements": [
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/reconnectDelay"
|
||||
},
|
||||
{
|
||||
"type": "Control",
|
||||
"scope": "#/properties/logPrices"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"scheduler": {
|
||||
"reason": "To schedule reconnection attempts on connection loss"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Discord Rich Presence Plugin (Rust)
|
||||
|
||||
A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the generated `nd-host` library.
|
||||
A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the `nd-pdk` library.
|
||||
|
||||
## ⚠️ Warning
|
||||
|
||||
@@ -21,20 +21,20 @@ This plugin is for **demonstration purposes only**. It requires storing your Dis
|
||||
|
||||
## Capabilities
|
||||
|
||||
This plugin implements three capabilities to demonstrate the nd-host library:
|
||||
This plugin implements multiple capabilities to demonstrate the nd-pdk library:
|
||||
|
||||
- **Scrobbler**: Receives now-playing events from Navidrome
|
||||
- **SchedulerCallback**: Handles heartbeat and activity clearing timers
|
||||
- **WebSocketCallback**: Communicates with Discord gateway
|
||||
- **WebSocketCallback**: Communicates with Discord gateway (text, binary, error, and close handlers)
|
||||
|
||||
## 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 |
|
||||
|---------------|-------------------------------------------|--------------------------------|
|
||||
| `clientid` | Your Discord application ID | `123456789012345678` |
|
||||
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
|
||||
| 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.
|
||||
|
||||
@@ -69,27 +69,30 @@ make discord-rich-presence-rs.ndp
|
||||
3. Enable and configure the plugin in the Navidrome UI (Settings → Plugins)
|
||||
4. Restart Navidrome if needed
|
||||
|
||||
## Using nd-host Library
|
||||
## Using nd-pdk Library
|
||||
|
||||
This plugin demonstrates how to use the generated Rust host function wrappers:
|
||||
This plugin demonstrates how to use the Rust plugin development kit:
|
||||
|
||||
```rust
|
||||
use nd_host::{artwork, cache, scheduler, websocket};
|
||||
use nd_pdk::host::{artwork, cache, scheduler, websocket};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Get artwork URL
|
||||
let (url, _) = artwork::artwork_get_track_url(track_id, 300)?;
|
||||
let url = artwork::get_track_url(track_id, 300)?;
|
||||
|
||||
// Cache operations
|
||||
cache::cache_set_string("key", "value", 3600)?;
|
||||
let (value, exists) = cache::cache_get_string("key")?;
|
||||
cache::set_string("key", "value", 3600)?;
|
||||
if let Some(value) = cache::get_string("key")? {
|
||||
// Use the cached value
|
||||
}
|
||||
|
||||
// Schedule tasks
|
||||
scheduler::scheduler_schedule_one_time(60, "payload", "task-id")?;
|
||||
scheduler::scheduler_schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?;
|
||||
scheduler::schedule_one_time(60, "payload", "task-id")?;
|
||||
scheduler::schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?;
|
||||
|
||||
// WebSocket operations
|
||||
let conn_id = websocket::websocket_connect("wss://example.com/socket")?;
|
||||
websocket::websocket_send_text(&conn_id, "Hello")?;
|
||||
let conn_id = websocket::connect("wss://example.com/socket", HashMap::new(), "my-conn")?;
|
||||
websocket::send_text(&conn_id, "Hello")?;
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -25,5 +25,74 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,9 @@
|
||||
//!
|
||||
//! ## Configuration
|
||||
//!
|
||||
//! ```toml
|
||||
//! [PluginConfig.discord-rich-presence-rs]
|
||||
//! clientid = "YOUR_DISCORD_APPLICATION_ID"
|
||||
//! "user.username1" = "discord_token1"
|
||||
//! "user.username2" = "discord_token2"
|
||||
//! ```
|
||||
//! Configure this plugin through the Navidrome UI with:
|
||||
//! - Discord Application Client ID
|
||||
//! - User tokens array mapping Navidrome usernames to Discord tokens
|
||||
//!
|
||||
//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens
|
||||
//! in configuration files is not secure and may violate Discord's terms of service.
|
||||
@@ -32,6 +29,7 @@ use nd_pdk::websocket::{
|
||||
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
|
||||
TextMessageProvider,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
mod rpc;
|
||||
|
||||
@@ -48,7 +46,7 @@ nd_pdk::register_websocket_close!(DiscordPlugin);
|
||||
// ============================================================================
|
||||
|
||||
const CLIENT_ID_KEY: &str = "clientid";
|
||||
const USER_KEY_PREFIX: &str = "user.";
|
||||
const USERS_KEY: &str = "users";
|
||||
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
|
||||
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
|
||||
|
||||
@@ -64,19 +62,31 @@ struct DiscordPlugin;
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
/// User token entry from the config schema
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserToken {
|
||||
username: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
|
||||
let client_id = config::get(CLIENT_ID_KEY)?
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
|
||||
|
||||
// Get all user keys with the "user." prefix
|
||||
let user_keys = config::keys(USER_KEY_PREFIX)?;
|
||||
|
||||
// Get users array from config (JSON format)
|
||||
let users_json = config::get(USERS_KEY)?.unwrap_or_default();
|
||||
|
||||
let mut users = std::collections::HashMap::new();
|
||||
for key in user_keys {
|
||||
let username = key.strip_prefix(USER_KEY_PREFIX).unwrap_or(&key);
|
||||
if let Some(token) = config::get(&key)?.filter(|s| !s.is_empty()) {
|
||||
users.insert(username.to_string(), token);
|
||||
if !users_json.is_empty() {
|
||||
// Parse JSON array of user tokens
|
||||
let user_tokens: Vec<UserToken> = serde_json::from_str(&users_json)
|
||||
.map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?;
|
||||
|
||||
for user_token in user_tokens {
|
||||
if !user_token.username.is_empty() && !user_token.token.is_empty() {
|
||||
users.insert(user_token.username, user_token.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,201 +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 (
|
||||
"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"
|
||||
userKeyPrefix = "user."
|
||||
)
|
||||
|
||||
// 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 all user keys with the "user." prefix
|
||||
userKeys := host.ConfigKeys(userKeyPrefix)
|
||||
if len(userKeys) == 0 {
|
||||
pdk.Log(pdk.LogWarn, "no users configured")
|
||||
return clientID, nil, nil
|
||||
}
|
||||
|
||||
users = make(map[string]string)
|
||||
for _, key := range userKeys {
|
||||
username := strings.TrimPrefix(key, userKeyPrefix)
|
||||
token, exists := host.ConfigGet(key)
|
||||
if exists && token != "" {
|
||||
users[username] = token
|
||||
}
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
pdk.Log(pdk.LogWarn, "no 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,33 +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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,105 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// testConfigInput is the input for nd_test_config callback.
|
||||
type testConfigInput struct {
|
||||
Operation string `json:"operation"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
// testConfigOutput is the output from nd_test_config callback.
|
||||
type testConfigOutput struct {
|
||||
StringVal string `json:"string_val,omitempty"`
|
||||
IntVal int64 `json:"int_val,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Exists bool `json:"exists,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// setupTestConfigPlugin sets up a test environment with the test-config plugin loaded.
|
||||
// Returns a cleanup function and a helper to call the plugin's nd_test_config function.
|
||||
func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, testConfigInput) (*testConfigOutput, error)) {
|
||||
tmpDir, err := os.MkdirTemp("", "config-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-config plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Compute SHA256 for the plugin
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(model.Plugins{{
|
||||
ID: "test-config",
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
AllUsers: true,
|
||||
Config: configJSON,
|
||||
}})
|
||||
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
||||
|
||||
// Create and start manager
|
||||
manager := &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
// Helper to call test plugin's exported function
|
||||
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-config"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
instance, err := p.instance(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer instance.Close(ctx)
|
||||
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var output testConfigOutput
|
||||
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Error != nil {
|
||||
return nil, errors.New(*output.Error)
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
return manager, callTestConfig
|
||||
}
|
||||
|
||||
var _ = Describe("ConfigService", func() {
|
||||
var service *configServiceImpl
|
||||
var ctx context.Context
|
||||
@@ -144,59 +243,12 @@ var _ = Describe("ConfigService", func() {
|
||||
|
||||
var _ = Describe("ConfigService Integration", Ordered, func() {
|
||||
var (
|
||||
manager *Manager
|
||||
tmpDir string
|
||||
manager *Manager
|
||||
callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
|
||||
)
|
||||
|
||||
BeforeAll(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "config-test-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Copy the test-config plugin
|
||||
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
|
||||
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
|
||||
data, err := os.ReadFile(srcPath)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = os.WriteFile(destPath, data, 0600)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Compute SHA256 for the plugin
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
// Setup config
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Plugins.Enabled = true
|
||||
conf.Server.Plugins.Folder = tmpDir
|
||||
conf.Server.Plugins.AutoReload = false
|
||||
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
|
||||
|
||||
// Setup mock DataStore with pre-enabled plugin and config
|
||||
mockPluginRepo := tests.CreateMockPluginRepo()
|
||||
mockPluginRepo.Permitted = true
|
||||
mockPluginRepo.SetData(model.Plugins{{
|
||||
ID: "test-config",
|
||||
Path: destPath,
|
||||
SHA256: hashHex,
|
||||
Enabled: true,
|
||||
Config: `{"api_key":"test_secret","max_retries":"5","timeout":"30"}`,
|
||||
}})
|
||||
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
|
||||
|
||||
// Create and start manager
|
||||
manager = &Manager{
|
||||
plugins: make(map[string]*plugin),
|
||||
ds: dataStore,
|
||||
subsonicRouter: http.NotFoundHandler(),
|
||||
}
|
||||
err = manager.Start(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
DeferCleanup(func() {
|
||||
_ = manager.Stop()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
manager, callTestConfig = setupTestConfigPlugin(`{"api_key":"test_secret","max_retries":"5","timeout":"30"}`)
|
||||
})
|
||||
|
||||
Describe("Plugin Loading", func() {
|
||||
@@ -205,54 +257,11 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
|
||||
p, ok := manager.plugins["test-config"]
|
||||
manager.mu.RUnlock()
|
||||
Expect(ok).To(BeTrue())
|
||||
// Config service doesn't require permission, so Permissions can be nil
|
||||
// Just verify the plugin loaded
|
||||
Expect(p.manifest.Name).To(Equal("Test Config Plugin"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Config Operations via Plugin", func() {
|
||||
type testConfigInput struct {
|
||||
Operation string `json:"operation"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
}
|
||||
type testConfigOutput struct {
|
||||
StringVal string `json:"string_val,omitempty"`
|
||||
IntVal int64 `json:"int_val,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Exists bool `json:"exists,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Helper to call test plugin's exported function
|
||||
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
|
||||
manager.mu.RLock()
|
||||
p := manager.plugins["test-config"]
|
||||
manager.mu.RUnlock()
|
||||
|
||||
instance, err := p.instance(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer instance.Close(ctx)
|
||||
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var output testConfigOutput
|
||||
if err := json.Unmarshal(outputBytes, &output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if output.Error != nil {
|
||||
return nil, errors.New(*output.Error)
|
||||
}
|
||||
return &output, nil
|
||||
}
|
||||
|
||||
It("should get string value", func() {
|
||||
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
||||
Operation: "get",
|
||||
@@ -285,7 +294,7 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
|
||||
It("should return not exists for non-integer value", func() {
|
||||
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
||||
Operation: "get_int",
|
||||
Key: "api_key", // This is a string, not an integer
|
||||
Key: "api_key",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Exists).To(BeFalse())
|
||||
@@ -310,3 +319,64 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Complex Config Values Integration", Ordered, func() {
|
||||
var callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
|
||||
|
||||
BeforeAll(func() {
|
||||
// Config with arrays and objects - these should be properly serialized as JSON strings
|
||||
_, callTestConfig = setupTestConfigPlugin(`{"api_key":"secret123","users":[{"username":"admin","token":"tok1"},{"username":"user2","token":"tok2"}],"settings":{"enabled":true,"count":5}}`)
|
||||
})
|
||||
|
||||
Describe("Config Serialization", func() {
|
||||
It("should make simple string config values accessible to plugin", func() {
|
||||
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
||||
Operation: "get",
|
||||
Key: "api_key",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Exists).To(BeTrue())
|
||||
Expect(output.StringVal).To(Equal("secret123"))
|
||||
})
|
||||
|
||||
It("should serialize array config values as JSON strings", func() {
|
||||
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
||||
Operation: "get",
|
||||
Key: "users",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Exists).To(BeTrue())
|
||||
// Array values are serialized as JSON strings - parse to verify structure
|
||||
var users []map[string]string
|
||||
Expect(json.Unmarshal([]byte(output.StringVal), &users)).To(Succeed())
|
||||
Expect(users).To(HaveLen(2))
|
||||
Expect(users[0]).To(HaveKeyWithValue("username", "admin"))
|
||||
Expect(users[0]).To(HaveKeyWithValue("token", "tok1"))
|
||||
Expect(users[1]).To(HaveKeyWithValue("username", "user2"))
|
||||
Expect(users[1]).To(HaveKeyWithValue("token", "tok2"))
|
||||
})
|
||||
|
||||
It("should serialize object config values as JSON strings", func() {
|
||||
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
||||
Operation: "get",
|
||||
Key: "settings",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Exists).To(BeTrue())
|
||||
// Object values are serialized as JSON strings - parse to verify structure
|
||||
var settings map[string]any
|
||||
Expect(json.Unmarshal([]byte(output.StringVal), &settings)).To(Succeed())
|
||||
Expect(settings).To(HaveKeyWithValue("enabled", true))
|
||||
Expect(settings).To(HaveKeyWithValue("count", float64(5)))
|
||||
})
|
||||
|
||||
It("should list all config keys including complex values", func() {
|
||||
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
|
||||
Operation: "list",
|
||||
Prefix: "",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(output.Keys).To(ConsistOf("api_key", "users", "settings"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -381,6 +381,30 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePluginConfig validates a config JSON string against the plugin's config schema.
|
||||
// If the plugin has no config schema defined, it returns an error.
|
||||
// Returns nil if validation passes, or an error describing the validation failure.
|
||||
func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error {
|
||||
if m.ds == nil {
|
||||
return fmt.Errorf("datastore not configured")
|
||||
}
|
||||
|
||||
adminCtx := adminContext(ctx)
|
||||
repo := m.ds.Plugin(adminCtx)
|
||||
|
||||
plugin, err := repo.Get(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting plugin from DB: %w", err)
|
||||
}
|
||||
|
||||
manifest, err := readManifest(plugin.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading manifest: %w", err)
|
||||
}
|
||||
|
||||
return ValidateConfig(manifest, configJSON)
|
||||
}
|
||||
|
||||
// UpdatePluginConfig updates the configuration for a plugin.
|
||||
// If the plugin is enabled, it will be reloaded with the new config.
|
||||
func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -230,11 +230,9 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
}
|
||||
|
||||
// Parse config from JSON
|
||||
var pluginConfig map[string]string
|
||||
if p.Config != "" {
|
||||
if err := json.Unmarshal([]byte(p.Config), &pluginConfig); err != nil {
|
||||
return fmt.Errorf("parsing plugin config: %w", err)
|
||||
}
|
||||
pluginConfig, err := parsePluginConfig(p.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse users from JSON
|
||||
@@ -336,8 +334,9 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
}
|
||||
|
||||
extismConfig := extism.PluginConfig{
|
||||
EnableWasi: true,
|
||||
RuntimeConfig: runtimeConfig,
|
||||
EnableWasi: true,
|
||||
RuntimeConfig: runtimeConfig,
|
||||
EnableHttpResponseHeaders: true,
|
||||
}
|
||||
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
|
||||
if err != nil {
|
||||
@@ -379,3 +378,30 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePluginConfig parses a JSON config string into a map of string values.
|
||||
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
|
||||
func parsePluginConfig(configJSON string) (map[string]string, error) {
|
||||
if configJSON == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rawConfig map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil {
|
||||
return nil, fmt.Errorf("parsing plugin config: %w", err)
|
||||
}
|
||||
pluginConfig := make(map[string]string)
|
||||
for key, value := range rawConfig {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
pluginConfig[key] = v
|
||||
default:
|
||||
// Serialize non-string values as JSON
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serializing config value %q: %w", key, err)
|
||||
}
|
||||
pluginConfig[key] = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
return pluginConfig, nil
|
||||
}
|
||||
|
||||
60
plugins/manager_loader_test.go
Normal file
60
plugins/manager_loader_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("parsePluginConfig", func() {
|
||||
It("returns nil for empty string", func() {
|
||||
result, err := parsePluginConfig("")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
|
||||
It("serializes object values as JSON strings", func() {
|
||||
result, err := parsePluginConfig(`{"settings": {"enabled": true, "count": 5}}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result["settings"]).To(Equal(`{"count":5,"enabled":true}`))
|
||||
})
|
||||
|
||||
It("handles mixed value types", func() {
|
||||
result, err := parsePluginConfig(`{"api_key": "secret", "timeout": 30, "rate": 1.5, "enabled": true, "tags": ["a", "b"]}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(5))
|
||||
Expect(result["api_key"]).To(Equal("secret"))
|
||||
Expect(result["timeout"]).To(Equal("30"))
|
||||
Expect(result["rate"]).To(Equal("1.5"))
|
||||
Expect(result["enabled"]).To(Equal("true"))
|
||||
Expect(result["tags"]).To(Equal(`["a","b"]`))
|
||||
})
|
||||
|
||||
It("returns error for invalid JSON", func() {
|
||||
_, err := parsePluginConfig(`{invalid json}`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
|
||||
})
|
||||
|
||||
It("returns error for non-object JSON", func() {
|
||||
_, err := parsePluginConfig(`["array", "not", "object"]`)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
|
||||
})
|
||||
|
||||
It("handles null values", func() {
|
||||
result, err := parsePluginConfig(`{"key": null}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result["key"]).To(Equal("null"))
|
||||
})
|
||||
|
||||
It("handles empty object", func() {
|
||||
result, err := parsePluginConfig(`{}`)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(0))
|
||||
Expect(result).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
@@ -36,9 +36,28 @@
|
||||
},
|
||||
"experimental": {
|
||||
"$ref": "#/$defs/Experimental"
|
||||
},
|
||||
"config": {
|
||||
"$ref": "#/$defs/ConfigDefinition"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"ConfigDefinition": {
|
||||
"type": "object",
|
||||
"description": "Configuration schema for the plugin using JSON Schema (draft-07) and optional JSONForms UI Schema",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema"],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"description": "JSON Schema (draft-07) defining the plugin's configuration options"
|
||||
},
|
||||
"uiSchema": {
|
||||
"type": "object",
|
||||
"description": "Optional JSONForms UI Schema for customizing form layout"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Experimental": {
|
||||
"type": "object",
|
||||
"description": "Experimental features that may change or be removed in future versions",
|
||||
@@ -134,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.",
|
||||
|
||||
@@ -3,6 +3,8 @@ package plugins
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json
|
||||
@@ -29,6 +31,26 @@ func (m *Manifest) Validate() error {
|
||||
return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate config schema if present
|
||||
if m.Config != nil && m.Config.Schema != nil {
|
||||
if err := validateConfigSchema(m.Config.Schema); err != nil {
|
||||
return fmt.Errorf("invalid config schema: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfigSchema validates that the schema is a valid JSON Schema that can be compiled.
|
||||
func validateConfigSchema(schema map[string]any) error {
|
||||
compiler := jsonschema.NewCompiler()
|
||||
if err := compiler.AddResource("schema.json", schema); err != nil {
|
||||
return fmt.Errorf("invalid schema structure: %w", err)
|
||||
}
|
||||
if _, err := compiler.Compile("schema.json"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,32 @@ type CachePermission struct {
|
||||
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// Configuration schema for the plugin using JSON Schema (draft-07) and optional
|
||||
// JSONForms UI Schema
|
||||
type ConfigDefinition struct {
|
||||
// JSON Schema (draft-07) defining the plugin's configuration options
|
||||
Schema map[string]interface{} `json:"schema" yaml:"schema" mapstructure:"schema"`
|
||||
|
||||
// Optional JSONForms UI Schema for customizing form layout
|
||||
UiSchema map[string]interface{} `json:"uiSchema,omitempty" yaml:"uiSchema,omitempty" mapstructure:"uiSchema,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(value, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := raw["schema"]; raw != nil && !ok {
|
||||
return fmt.Errorf("field schema in ConfigDefinition: required")
|
||||
}
|
||||
type Plain ConfigDefinition
|
||||
var plain Plain
|
||||
if err := json.Unmarshal(value, &plain); err != nil {
|
||||
return err
|
||||
}
|
||||
*j = ConfigDefinition(plain)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Experimental features that may change or be removed in future versions
|
||||
@@ -81,6 +103,9 @@ type Manifest struct {
|
||||
// The author of the plugin
|
||||
Author string `json:"author" yaml:"author" mapstructure:"author"`
|
||||
|
||||
// Config corresponds to the JSON schema field "config".
|
||||
Config *ConfigDefinition `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"`
|
||||
|
||||
// A brief description of what the plugin does
|
||||
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user