875c29b2d1
When Navidrome is behind a private network (e.g. Tailscale), Discord cannot fetch artwork URLs. This adds optional integration with imgbb (24h expiry, requires API key) and uguu.se (3h expiry, no key needed) to upload cover art to a public host. Closes #1 # Conflicts: # go.mod # go.sum
219 lines
7.8 KiB
Go
219 lines
7.8 KiB
Go
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/scheduler"
|
|
"github.com/navidrome/navidrome/plugins/pdk/go/scrobbler"
|
|
"github.com/stretchr/testify/mock"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
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
|
|
host.SubsonicAPIMock.ExpectedCalls = nil
|
|
host.SubsonicAPIMock.Calls = nil
|
|
})
|
|
|
|
Describe("getConfig", func() {
|
|
It("returns config values when properly set", func() {
|
|
pdk.PDKMock.On("GetConfig", clientIDKey).Return("test-client-id", true)
|
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"user1","token":"token1"},{"username":"user2","token":"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)
|
|
pdk.PDKMock.On("GetConfig", usersKey).Return("", false)
|
|
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)
|
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"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)
|
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"otheruser","token":"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)
|
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"otheruser","token":"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)
|
|
pdk.PDKMock.On("GetConfig", usersKey).Return(`[{"username":"testuser","token":"test-token"}]`, true)
|
|
pdk.PDKMock.On("GetConfig", imageHostKey).Return("", false)
|
|
|
|
// 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())
|
|
})
|
|
})
|
|
})
|